From 8514c63ec6d039da1952bff2a7a710af69d952b4 Mon Sep 17 00:00:00 2001 From: Mikan <72257910+Mikan-DS@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:13:05 +0300 Subject: [PATCH] rebase --- .env.example | 65 +- .gitignore | 59 +- CHANGELOG.md | 92 + README.md | 382 +- app/__init__.py | 3 + app/api/__init__.py | 1 + app/api/admin.py | 401 ++ app/api/auth.py | 224 + app/api/deps.py | 64 + app/api/misc.py | 74 + app/api/presets.py | 125 + app/api/sessions.py | 348 ++ app/api/worlds.py | 182 + app/config.py | 125 + app/core/__init__.py | 1 + app/core/embeddings.py | 153 + app/core/llm.py | 462 ++ app/core/logging.py | 48 + app/core/qdrant_client.py | 130 + app/core/rag.py | 326 ++ app/core/security.py | 85 + app/core/settings_service.py | 187 + app/core/state_validator.py | 307 ++ app/core/time_utils.py | 148 + app/db.py | 70 + app/engine/__init__.py | 1 + app/engine/context.py | 180 + app/engine/game_master.py | 314 ++ app/engine/sse.py | 84 + app/engine/tools/__init__.py | 1 + app/engine/tools/base.py | 231 + app/engine/tools/game.py | 993 ++++ app/engine/tools/register_all.py | 56 + app/engine/tools/schema_tools.py | 146 + app/engine/world_builder.py | 301 ++ app/engine/world_editor.py | 148 + app/main.py | 123 + app/migrations/__init__.py | 1 + app/migrations/init_db.py | 14 + app/migrations/init_qdrant.py | 11 + app/migrations/seed.py | 273 + app/migrations/versions/001_initial_schema.py | 29 + app/models/__init__.py | 408 ++ app/prompts/__init__.py | 1 + app/prompts/registry.py | 49 + app/prompts/stages/__init__.py | 1 + app/prompts/stages/intro_scene.py | 33 + app/prompts/stages/orchestrator_phase1.py | 47 + app/prompts/stages/orchestrator_phase2.py | 35 + .../stages/orchestrator_phase3_suggest.py | 23 + .../stages/orchestrator_phase3_summary.py | 18 + app/prompts/stages/subagent.py | 27 + app/prompts/stages/summary.py | 5 + app/prompts/stages/world_builder_entities.py | 34 + app/prompts/stages/world_builder_env.py | 36 + app/prompts/stages/world_builder_schema.py | 48 + app/prompts/stages/world_editor.py | 34 + app/schemas/__init__.py | 286 ++ backend/Dockerfile | 29 - backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/admin.py | 437 -- backend/app/api/auth.py | 89 - backend/app/api/misc.py | 90 - backend/app/api/presets.py | 71 - backend/app/api/sessions.py | 191 - backend/app/api/worlds.py | 217 - backend/app/config.py | 117 - backend/app/core/__init__.py | 0 backend/app/core/llm.py | 305 -- backend/app/core/rag.py | 463 -- backend/app/core/security.py | 44 - backend/app/core/settings_service.py | 95 - backend/app/core/state_validator.py | 108 - backend/app/core/triggers.py | 310 -- backend/app/db.py | 49 - backend/app/db_wait.py | 81 - backend/app/deps.py | 40 - backend/app/engine/__init__.py | 0 backend/app/engine/context.py | 258 - backend/app/engine/orchestrator.py | 615 --- backend/app/engine/tools/__init__.py | 0 backend/app/engine/tools/tools.py | 545 -- backend/app/engine/world_builder.py | 463 -- backend/app/engine/world_editor.py | 279 -- backend/app/logging_setup.py | 83 - backend/app/main.py | 82 - backend/app/migrations/__init__.py | 0 backend/app/migrations/init_db.py | 189 - backend/app/models/__init__.py | 189 - backend/app/prompts/__init__.py | 0 backend/app/prompts/fantasy_preset.py | 225 - backend/app/prompts/templates.py | 197 - backend/app/schemas/__init__.py | 251 - backend/app/workers/__init__.py | 0 backend/app/workers/main.py | 41 - backend/app/workers/trigger_runner.py | 203 - backend/requirements.txt | 23 - deploy/Dockerfile.backend | 22 + deploy/Dockerfile.frontend | 14 + {frontend => deploy}/nginx.conf | 13 +- docker-compose.yml | 148 +- docs/AI-RPG_TZ_TDD.md | 4418 +++++++++++++++++ frontend/Dockerfile | 23 - frontend/index.html | 10 +- frontend/package-lock.json | 4309 +++++++++++----- frontend/package.json | 50 +- frontend/public/.gitkeep | 0 frontend/public/{logo.png => icon.png} | Bin frontend/src/App.tsx | 238 +- frontend/src/api/index.ts | 269 - frontend/src/components/admin/IconsPanel.tsx | 78 + .../src/components/admin/LlmLogsTable.tsx | 256 + .../src/components/admin/SettingsPanel.tsx | 132 + frontend/src/components/admin/StatsPanel.tsx | 54 + frontend/src/components/admin/TestButtons.tsx | 301 ++ frontend/src/components/admin/UsersTable.tsx | 118 + .../src/components/auth/ProtectedRoute.tsx | 41 + .../src/components/sessions/ActionInput.tsx | 81 + frontend/src/components/sessions/ChatView.tsx | 143 + .../src/components/sessions/PhaseProgress.tsx | 71 + .../src/components/sessions/SseStatus.tsx | 34 + .../components/sessions/ToolCallBubble.tsx | 45 + frontend/src/components/ui/Button.tsx | 80 +- frontend/src/components/ui/Card.tsx | 46 +- frontend/src/components/ui/Input.tsx | 95 +- frontend/src/components/ui/JsonEditor.tsx | 75 + frontend/src/components/ui/Modal.tsx | 80 +- frontend/src/components/ui/Navbar.tsx | 230 +- frontend/src/components/ui/Spinner.tsx | 27 + frontend/src/components/ui/Textarea.tsx | 33 + frontend/src/components/ui/Toast.tsx | 98 + frontend/src/components/ui/cn.ts | 6 - frontend/src/components/ui/ui-overview.ts | 5 - .../src/components/world/CharacterSheet.tsx | 104 - .../src/components/world/GlossaryModal.tsx | 60 - .../src/components/worlds/WorldBuilder.tsx | 468 ++ frontend/src/components/worlds/WorldCard.tsx | 110 + .../src/components/worlds/WorldEditor.tsx | 285 ++ frontend/src/i18n/en.json | 265 + frontend/src/i18n/en.ts | 197 - frontend/src/i18n/index.ts | 17 +- frontend/src/i18n/ru.json | 265 + frontend/src/i18n/ru.ts | 197 - frontend/src/index.css | 111 +- frontend/src/lib/api.ts | 301 ++ frontend/src/lib/cn.ts | 10 + frontend/src/lib/sse.ts | 151 + frontend/src/main.tsx | 24 +- frontend/src/pages/AdminPage.tsx | 70 + frontend/src/pages/AdminPanelPage.tsx | 655 --- frontend/src/pages/AdminRegisterPage.tsx | 119 + frontend/src/pages/AdminSetupPage.tsx | 91 - frontend/src/pages/DashboardPage.tsx | 153 - frontend/src/pages/HomePage.tsx | 90 - frontend/src/pages/LoginPage.tsx | 99 +- frontend/src/pages/PlayPage.tsx | 233 + frontend/src/pages/RegisterPage.tsx | 136 +- frontend/src/pages/SessionPage.tsx | 440 -- frontend/src/pages/WorldBuilderPage.tsx | 266 +- frontend/src/pages/WorldCreatePage.tsx | 143 - frontend/src/pages/WorldEditPage.tsx | 338 +- frontend/src/pages/WorldsListPage.tsx | 100 + frontend/src/store/auth.ts | 24 - frontend/src/store/ui.ts | 38 - frontend/src/stores/authStore.ts | 121 + frontend/src/stores/sessionStore.ts | 308 ++ frontend/src/stores/toastStore.ts | 40 + frontend/src/stores/uiStore.ts | 83 + frontend/src/stores/worldsStore.ts | 71 + frontend/src/types/index.ts | 487 +- frontend/tailwind.config.js | 37 + frontend/tailwind.config.ts | 41 - frontend/tsconfig.check.json | 20 - frontend/tsconfig.json | 5 +- frontend/tsconfig.node.json | 11 - frontend/tsconfig.tsbuildinfo | 1 + frontend/vite.config.ts | 25 +- pytest.ini | 9 + requirements.txt | 29 + tests/__init__.py | 1 + tests/conftest.py | 25 + tests/integration/__init__.py | 1 + tests/unit/__init__.py | 1 + tests/unit/test_embeddings.py | 69 + tests/unit/test_llm_mock.py | 76 + tests/unit/test_prompts.py | 58 + tests/unit/test_security.py | 89 + tests/unit/test_sse_emitter.py | 79 + tests/unit/test_state_validator.py | 166 + tests/unit/test_time_utils.py | 58 + tests/unit/test_tool_registry.py | 114 + tests/unit/test_tools_calc_random.py | 105 + 193 files changed, 22105 insertions(+), 11660 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/admin.py create mode 100644 app/api/auth.py create mode 100644 app/api/deps.py create mode 100644 app/api/misc.py create mode 100644 app/api/presets.py create mode 100644 app/api/sessions.py create mode 100644 app/api/worlds.py create mode 100644 app/config.py create mode 100644 app/core/__init__.py create mode 100644 app/core/embeddings.py create mode 100644 app/core/llm.py create mode 100644 app/core/logging.py create mode 100644 app/core/qdrant_client.py create mode 100644 app/core/rag.py create mode 100644 app/core/security.py create mode 100644 app/core/settings_service.py create mode 100644 app/core/state_validator.py create mode 100644 app/core/time_utils.py create mode 100644 app/db.py create mode 100644 app/engine/__init__.py create mode 100644 app/engine/context.py create mode 100644 app/engine/game_master.py create mode 100644 app/engine/sse.py create mode 100644 app/engine/tools/__init__.py create mode 100644 app/engine/tools/base.py create mode 100644 app/engine/tools/game.py create mode 100644 app/engine/tools/register_all.py create mode 100644 app/engine/tools/schema_tools.py create mode 100644 app/engine/world_builder.py create mode 100644 app/engine/world_editor.py create mode 100644 app/main.py create mode 100644 app/migrations/__init__.py create mode 100644 app/migrations/init_db.py create mode 100644 app/migrations/init_qdrant.py create mode 100644 app/migrations/seed.py create mode 100644 app/migrations/versions/001_initial_schema.py create mode 100644 app/models/__init__.py create mode 100644 app/prompts/__init__.py create mode 100644 app/prompts/registry.py create mode 100644 app/prompts/stages/__init__.py create mode 100644 app/prompts/stages/intro_scene.py create mode 100644 app/prompts/stages/orchestrator_phase1.py create mode 100644 app/prompts/stages/orchestrator_phase2.py create mode 100644 app/prompts/stages/orchestrator_phase3_suggest.py create mode 100644 app/prompts/stages/orchestrator_phase3_summary.py create mode 100644 app/prompts/stages/subagent.py create mode 100644 app/prompts/stages/summary.py create mode 100644 app/prompts/stages/world_builder_entities.py create mode 100644 app/prompts/stages/world_builder_env.py create mode 100644 app/prompts/stages/world_builder_schema.py create mode 100644 app/prompts/stages/world_editor.py create mode 100644 app/schemas/__init__.py delete mode 100644 backend/Dockerfile delete mode 100644 backend/app/__init__.py delete mode 100644 backend/app/api/__init__.py delete mode 100644 backend/app/api/admin.py delete mode 100644 backend/app/api/auth.py delete mode 100644 backend/app/api/misc.py delete mode 100644 backend/app/api/presets.py delete mode 100644 backend/app/api/sessions.py delete mode 100644 backend/app/api/worlds.py delete mode 100644 backend/app/config.py delete mode 100644 backend/app/core/__init__.py delete mode 100644 backend/app/core/llm.py delete mode 100644 backend/app/core/rag.py delete mode 100644 backend/app/core/security.py delete mode 100644 backend/app/core/settings_service.py delete mode 100644 backend/app/core/state_validator.py delete mode 100644 backend/app/core/triggers.py delete mode 100644 backend/app/db.py delete mode 100644 backend/app/db_wait.py delete mode 100644 backend/app/deps.py delete mode 100644 backend/app/engine/__init__.py delete mode 100644 backend/app/engine/context.py delete mode 100644 backend/app/engine/orchestrator.py delete mode 100644 backend/app/engine/tools/__init__.py delete mode 100644 backend/app/engine/tools/tools.py delete mode 100644 backend/app/engine/world_builder.py delete mode 100644 backend/app/engine/world_editor.py delete mode 100644 backend/app/logging_setup.py delete mode 100644 backend/app/main.py delete mode 100644 backend/app/migrations/__init__.py delete mode 100644 backend/app/migrations/init_db.py delete mode 100644 backend/app/models/__init__.py delete mode 100644 backend/app/prompts/__init__.py delete mode 100644 backend/app/prompts/fantasy_preset.py delete mode 100644 backend/app/prompts/templates.py delete mode 100644 backend/app/schemas/__init__.py delete mode 100644 backend/app/workers/__init__.py delete mode 100644 backend/app/workers/main.py delete mode 100644 backend/app/workers/trigger_runner.py delete mode 100644 backend/requirements.txt create mode 100644 deploy/Dockerfile.backend create mode 100644 deploy/Dockerfile.frontend rename {frontend => deploy}/nginx.conf (75%) create mode 100644 docs/AI-RPG_TZ_TDD.md delete mode 100644 frontend/Dockerfile delete mode 100644 frontend/public/.gitkeep rename frontend/public/{logo.png => icon.png} (100%) delete mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/components/admin/IconsPanel.tsx create mode 100644 frontend/src/components/admin/LlmLogsTable.tsx create mode 100644 frontend/src/components/admin/SettingsPanel.tsx create mode 100644 frontend/src/components/admin/StatsPanel.tsx create mode 100644 frontend/src/components/admin/TestButtons.tsx create mode 100644 frontend/src/components/admin/UsersTable.tsx create mode 100644 frontend/src/components/auth/ProtectedRoute.tsx create mode 100644 frontend/src/components/sessions/ActionInput.tsx create mode 100644 frontend/src/components/sessions/ChatView.tsx create mode 100644 frontend/src/components/sessions/PhaseProgress.tsx create mode 100644 frontend/src/components/sessions/SseStatus.tsx create mode 100644 frontend/src/components/sessions/ToolCallBubble.tsx create mode 100644 frontend/src/components/ui/JsonEditor.tsx create mode 100644 frontend/src/components/ui/Spinner.tsx create mode 100644 frontend/src/components/ui/Textarea.tsx create mode 100644 frontend/src/components/ui/Toast.tsx delete mode 100644 frontend/src/components/ui/cn.ts delete mode 100644 frontend/src/components/ui/ui-overview.ts delete mode 100644 frontend/src/components/world/CharacterSheet.tsx delete mode 100644 frontend/src/components/world/GlossaryModal.tsx create mode 100644 frontend/src/components/worlds/WorldBuilder.tsx create mode 100644 frontend/src/components/worlds/WorldCard.tsx create mode 100644 frontend/src/components/worlds/WorldEditor.tsx create mode 100644 frontend/src/i18n/en.json delete mode 100644 frontend/src/i18n/en.ts create mode 100644 frontend/src/i18n/ru.json delete mode 100644 frontend/src/i18n/ru.ts create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/cn.ts create mode 100644 frontend/src/lib/sse.ts create mode 100644 frontend/src/pages/AdminPage.tsx delete mode 100644 frontend/src/pages/AdminPanelPage.tsx create mode 100644 frontend/src/pages/AdminRegisterPage.tsx delete mode 100644 frontend/src/pages/AdminSetupPage.tsx delete mode 100644 frontend/src/pages/DashboardPage.tsx delete mode 100644 frontend/src/pages/HomePage.tsx create mode 100644 frontend/src/pages/PlayPage.tsx delete mode 100644 frontend/src/pages/SessionPage.tsx delete mode 100644 frontend/src/pages/WorldCreatePage.tsx create mode 100644 frontend/src/pages/WorldsListPage.tsx delete mode 100644 frontend/src/store/auth.ts delete mode 100644 frontend/src/store/ui.ts create mode 100644 frontend/src/stores/authStore.ts create mode 100644 frontend/src/stores/sessionStore.ts create mode 100644 frontend/src/stores/toastStore.ts create mode 100644 frontend/src/stores/uiStore.ts create mode 100644 frontend/src/stores/worldsStore.ts create mode 100644 frontend/tailwind.config.js delete mode 100644 frontend/tailwind.config.ts delete mode 100644 frontend/tsconfig.check.json delete mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/tsconfig.tsbuildinfo create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_embeddings.py create mode 100644 tests/unit/test_llm_mock.py create mode 100644 tests/unit/test_prompts.py create mode 100644 tests/unit/test_security.py create mode 100644 tests/unit/test_sse_emitter.py create mode 100644 tests/unit/test_state_validator.py create mode 100644 tests/unit/test_time_utils.py create mode 100644 tests/unit/test_tool_registry.py create mode 100644 tests/unit/test_tools_calc_random.py diff --git a/.env.example b/.env.example index a3cd274..841d6c3 100644 --- a/.env.example +++ b/.env.example @@ -1,39 +1,38 @@ # === Database === -POSTGRES_DB=airpg POSTGRES_USER=airpg -POSTGRES_PASSWORD=airpg_secret +POSTGRES_PASSWORD=airpg +POSTGRES_DB=airpg +DATABASE_URL=postgresql+asyncpg://airpg:airpg@db:5432/airpg +DATABASE_URL_SYNC=postgresql+psycopg2://airpg:airpg@db:5432/airpg -# === Auth === -JWT_SECRET=change_me_in_production_please -# If empty, the backend will generate and print a one-time admin setup token to console on first run. +# === Qdrant === +QDRANT_URL=http://qdrant:6333 +QDRANT_API_KEY= + +# === LLM === +LLM_API_URL=http://host.docker.internal:11434/v1 +LLM_API_KEY= +LLM_MODEL=qwen2.5-7b-instruct +LLM_TIMEOUT_SECONDS=60 + +# === Embeddings === +EMBEDDINGS_PROVIDER=offline_hash +EMBEDDINGS_API_URL= +EMBEDDINGS_API_KEY= +EMBEDDINGS_MODEL=text-embedding-3-small +EMBEDDINGS_DIMENSION=256 + +# === Application === +SECRET_KEY=change-me-in-production-32-bytes-long-min +DEBUG=false +LOG_LEVEL=INFO +CORS_ORIGINS=http://localhost,http://localhost:5173 + +# === Admin setup === ADMIN_SETUP_TOKEN= -# === CORS === -CORS_ORIGINS=http://localhost:5173,http://localhost:8080 +# === Storage === +DATA_DIR=/app/data -# === Logging === -LOG_LEVEL=INFO - -# === Default LLM (overridable via admin panel) === -# Any OpenAI-compatible endpoint (vLLM, llama.cpp server, LM Studio, Ollama with /v1, OpenAI, OpenRouter, etc.) -DEFAULT_LLM_BASE_URL=http://host.docker.internal:1234/v1 -DEFAULT_LLM_API_KEY=dummy -DEFAULT_LLM_MODEL=local-model - -# === Default embeddings / RAG (overridable via admin panel) === -# provider: "hash" (offline fallback, no semantic quality) or "openai" (real /embeddings endpoint). -# When provider=openai and base_url/api_key are empty, they fall back to the LLM settings above. -DEFAULT_EMBEDDING_PROVIDER=hash -DEFAULT_EMBEDDING_BASE_URL= -DEFAULT_EMBEDDING_API_KEY= -DEFAULT_EMBEDDING_MODEL=text-embedding-3-small -DEFAULT_EMBEDDING_DIM=0 -DEFAULT_EMBEDDING_REQUEST_TIMEOUT=60 - -# === Frontend === -# Vite dev-server proxy target — INSIDE the frontend container "localhost" is -# the container itself, so docker-compose defaults this to http://backend:8000 -# (the service name). Override only if you run vite outside docker against a -# host backend (in which case use http://localhost:8000). -API_PROXY_TARGET=http://backend:8000 -FRONTEND_BUILD_TARGET=dev +# === Frontend (Vite dev) === +VITE_API_BASE_URL=http://localhost/api diff --git a/.gitignore b/.gitignore index cce0ce7..a2f0617 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,56 @@ +# Python __pycache__/ -*.pyc -*.pyo -*.pyd +*.py[cod] +*$py.class +*.so .Python -*.egg-info/ .venv/ venv/ env/ -.env - -node_modules/ +ENV/ +build/ dist/ -.vite/ +*.egg-info/ +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.mypy_cache/ +.ruff_cache/ +# Node / Frontend +node_modules/ +frontend/dist/ +frontend/.vite/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Data +data/ +!data/.gitkeep +*.db +*.sqlite3 + +# OS .DS_Store -*.log +Thumbs.db -postgres_data/ -redis_data/ -qdrant_data/ +# Logs +*.log +logs/ + +# Uploaded assets (kept on host volume, not in repo) +data/assets/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..aac0de8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,92 @@ +# Changelog + +All notable changes to AI-RPG are documented here. +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] — 2026-06-20 + +### Added — Sprint 1: Foundation +- docker-compose with 4 services: db (PostgreSQL 15), qdrant (1.9), backend (FastAPI/uvicorn), frontend (Vite dev / nginx prod). +- SQLAlchemy 2.x async models for all 10 tables: `users`, `settings`, `world_presets`, `worlds`, `entities`, `steps`, `step_tool_calls`, `deferred_triggers`, `story_entries`, `llm_call_logs`. +- Alembic-equivalent idempotent migration `001_initial_schema.py` (creates all tables, no pgvector). +- `init_qdrant.py` creates collections `entities` and `story_entries` with payload indexes on `world_id`, `entity_type`, `entry_type`, `deleted`, `created_at`. +- Settings seed: 33 default keys (llm.*, embeddings.*, context.*, qdrant.*, game.*, ui.*, admin.setup_token). +- 2 builtin presets: Classic Fantasy, Deep Space Outpost. +- `GET /api/health` returns `{status, db, qdrant, llm, embeddings, version}`. + +### Added — Sprint 2: Auth + Admin +- `POST /api/register` (only if at least one admin exists), `POST /api/register/admin?token=`, `POST /api/auth/login` (email or username), `POST /api/auth/refresh`, `POST /api/auth/logout`, `GET /api/auth/me`. +- JWT (access + refresh) with python-jose, password hashing with passlib[bcrypt]. +- Password strength validation: ≥8 chars, ≥1 letter, ≥1 digit, blacklist of trivial passwords. +- Anti-enumeration: same `401 invalid_credentials` for "user not found" and "wrong password". +- Admin setup token: auto-generated on first startup, printed in logs. +- `GET/PATCH /api/admin/settings` with secret masking. +- `GET /api/admin/llm-logs` with filters (world_id, stage, status, pagination) + detail view. +- `GET /api/admin/users`, `PATCH /api/admin/users/{id}` (admin/active toggle). +- `GET /api/admin/stats` (users, worlds, steps, avg LLM latency). +- Diagnostic endpoints: `POST /api/admin/test/llm`, `/test/llm-tools`, `/test/embeddings`, `/test/embeddings/probe-dimension`, `/embeddings/recreate-collections`. +- `POST /api/admin/upload-icon` (multipart, favicon/logo/og_image, ≤1MB, PNG/SVG/JPG/WebP/ICO). + +### Added — Sprint 3: World Builder +- `LlmClient` — OpenAI-compatible chat completions client with retry (3 attempts, exp backoff), streaming, tool-calling, separate-transaction logging. +- `MockLlmClient` — replay-based mock for tests/dev when no LLM configured. +- `app/prompts/` — 11 stage prompts in English (world_builder_schema/env/entities, world_editor, orchestrator_phase1/2/3_summary/3_suggest, intro_scene, subagent, summary). +- `POST /api/worlds` creates draft world + returns SSE URL. +- `GET /api/sessions/worlds/{id}/builder/stream` runs the 4-step builder flow: schemas → environment → entities (tool-calling loop) → intro scene (with submit_step + suggest_actions). +- If preset_id provided, schemas/environment copied from preset (skip first LLM call). + +### Added — Sprint 4: World Editor +- `POST /api/worlds/{id}/edit` body `{instruction}` → SSE URL. +- `GET /api/sessions/worlds/{id}/editor/stream?instruction=` runs LLM with world_editor tools. +- `propose_changes` tool returns diff, `ask_user` emits clarification event, `comment_to_user` for chat. +- Optimistic locking via `updated_at` on `PATCH /api/worlds/{id}` (409 state_conflict on mismatch). +- `PATCH /api/worlds/{id}` for direct JSON edits. +- `DELETE /api/worlds/{id}` soft-delete (status='archived'). + +### Added — Sprint 5: Orchestrator +- `POST /api/sessions/worlds/{id}/iterate` body `{action, action_source}` creates new step + returns SSE URL. +- `GET /api/sessions/worlds/{id}/iterate/stream?step_id=` runs the 3-phase orchestrator: + - **Phase 1**: tool-calling loop with `submit_plan` terminator, max 8 substeps (configurable). + - **Phase 2**: writer LLM call with `submit_step`, streams `scene_chunk` events. + - **Phase 3**: persist + deferred triggers + summary (if history > threshold) + suggest_actions. +- `POST /api/sessions/worlds/{id}/retry` soft-deletes last step + creates new one with same action. +- `POST /api/sessions/worlds/{id}/rollback` soft-deletes last step. +- 17 game tools: entity_create/get/list/update/delete, env_update/get, update_plot_rails, advance_time, schedule_trigger, calc (with dice), random_choice (deterministic), rag_query/add, run_subagent, submit_plan, submit_step, suggest_actions. +- 4 schema tools: schema_add_type/add_field/remove_field/modify_field. + +### Added — Sprint 6: Frontend polish +- React 18 + Vite 5 + TypeScript 5 + Tailwind CSS 3 + zustand 4 + react-i18next 14. +- Dark theme by default (Tailwind `dark:` class on ``). +- 8 pages: Login, Register, AdminRegister, WorldsList, WorldBuilder, WorldEdit, Play, Admin. +- 23 components: 9 UI primitives, 5 session, 3 worlds, 6 admin. +- 5 zustand stores: auth, ui, worlds, session, toast. +- i18n bundles in English + Russian (complete). +- Native EventSource SSE client with reconnect + Last-Event-ID. +- Mobile-first responsive layout. +- TypeScript strict mode, 0 type errors, `npm run build` succeeds (340 KB JS / 22 KB CSS). + +### Added — Sprint 7: RAG + Triggers + Summary + Context +- `app/core/rag.py` — two-stage retrieval: Qdrant vector search → PostgreSQL hydration. +- `HashEmbedder` (offline, deterministic) and `OpenAIEmbedder` (OpenAI-compatible API) implementations. +- Embedder provider selection via `embeddings.provider` setting (`offline_hash` | `openai`). +- Auto-fallback: `embeddings.api_url` falls back to `llm.api_url` if empty (and same for api_key). +- Embedder cache with `reset_embedder_cache()` (called on settings update). +- `rag_query` returns empty list on embedder/Qdrant failure (non-blocking). +- `rag_add` saves story entry with `embedding_status='pending'` if embedding fails (background indexer can retry). +- `index_entity` helper for entity vectors. +- World isolation via Qdrant payload filter `world_id`. +- `_cleanup_qdrant(world_id)` deletes all points for a world on world delete (best-effort). +- Deferred triggers: Phase 3.1 fires triggers where `fire_at <= current_time`, appends summary to scene_text, marks `is_fired=true`. +- Summary: Phase 3.2 generates summary when `len(recent_steps) > compression_threshold_messages`, stores as StoryEntry with `metadata.type=summary`. +- Context manager: builds messages with system prompt + optional summary + last N guaranteed messages + current action. + +### Added — Sprint 8: Production +- `README.md` with quickstart, architecture diagram, API overview, testing instructions. +- `pytest.ini` + 65+ unit tests across 8 test files. +- `CHANGELOG.md` (this file). +- `docker-compose.yml` with healthchecks for db and qdrant. +- `deploy/Dockerfile.backend`, `deploy/Dockerfile.frontend`, `deploy/nginx.conf`. +- `.env.example` with all 24 env vars documented. +- `.gitignore` for Python, Node, env, IDE, data dirs. +- Frontend production build verified (`npm run build` produces `dist/`). diff --git a/README.md b/README.md index 644cbf5..819840e 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,330 @@ -# AI RPG — гибкая ролевая игра с ИИ +# AI-RPG — Text RPG with an LLM Game Master -Веб-приложение для проведения ролевых игр с искусственным интеллектом. -FastAPI (backend) + React/Vite/TypeScript (frontend) + PostgreSQL + Redis + Qdrant. -Упаковано в Docker Compose. +> **Version:** 1.0.0 +> **Stack:** Python 3.12 · FastAPI · SQLAlchemy 2.x async · PostgreSQL 15 · Qdrant 1.9 · React 18 · Vite 5 · TypeScript 5 · Tailwind CSS 3 · zustand 4 · react-i18next 14 -## Возможности +AI-RPG — это веб-приложение, в котором игрок ведёт текстовую ролевую игру с ИИ-мастером (GM). Игрок создаёт мир (или выбирает готовый пресет), настраивает персонажа, и далее вступает в пошаговое взаимодействие: каждое действие игрока обрабатывается трёхфазным orchestrator-ом, который генерирует нарратив, обновляет состояние мира через tool calls, и предлагает 1-3 следующих действия. -- Регистрация / аутентификация пользователей -- Первичная инициализация администратора по одноразовому токену (выводится в консоль backend при первом запуске) -- Панель администратора: настройка OpenAI-совместимого endpoint'а, токена, модели, параметров генерации, лимитов контекста -- Создание мира: пресет (встроенный Fantasy) или с нуля через многошаговый диалог с ИИ -- ИИ генерирует: расширенный сеттинг, правила, JSON-схему состояния мира (характеристики, инвентарь, статы), начальную дату/время, общие рельсы сюжета -- Игрок правит и подтверждает → запускается первая итерация -- Сессия: чат-интерфейс со стримингом, глоссарий (RAG), лист персонажа, кнопки опций + свободный ввод -- Итерация: ИИ использует инструменты (dice, RAG, обновление состояния, sub-агенты, отложенные триггеры) и пишет сценарный шаг + технический "за-кадровый" шаг -- Контекстный менеджер: гарантированные последние N сообщений + динамическая граница с суммаризацией -- Отложенные триггеры, привязанные к дате/времени мира (background worker) -- Двуязычный UI (RU/EN) -- Логирование всех LLM-вызовов в БД +Полное ТЗ — в `docs/AI-RPG_TZ_TDD.md`. -## Быстрый старт +--- -```bash -# 1. Скопировать .env и при необходимости отредактировать -cp .env.example .env +## Возможности (v1.0.0) -# 2. Поднять стек -docker compose up --build +- ✅ **JWT-аутентификация** — регистрация обычных пользователей и первого админа по токену. +- ✅ **Админ-панель** — настройки (LLM, embeddings, Qdrant, UI, game), логи LLM-вызовов с фильтрами, список пользователей, статистика, диагностические кнопки (test LLM / test LLM tools / test embeddings / probe dimension / recreate collections), загрузка favicon/logo/OG-image. +- ✅ **Пресеты миров** — 2 встроенных (Classic Fantasy, Deep Space Outpost) + создание/редактирование своих. +- ✅ **World Builder** — генерация нового мира из пресета или формы через SSE-стрим: schemas → environment → entities → intro scene. +- ✅ **World Editor** — чат-инструция для LLM, `propose_changes` с diff, `ask_user` для уточнений, optimistic locking. +- ✅ **Orchestrator (3 фазы)**: + - **Phase 1**: planner+executor — цикл tool-calls до `submit_plan`. + - **Phase 2**: writer — single LLM call с `submit_step`, стриминг `scene_chunk`. + - **Phase 3**: persist + deferred triggers + summary (если история длинная) + suggest actions. +- ✅ **RAG через Qdrant** — `rag_query` / `rag_add`, двухстадийный retrieval (Qdrant → PostgreSQL), изоляция миров через payload-фильтр, фоновая индексация (через `embedding_status`). +- ✅ **Embeddings** — `HashEmbedder` (offline, для dev) и `OpenAIEmbedder` (OpenAI-compatible API), авто-fallback `embeddings.api_url` → `llm.api_url`. +- ✅ **Игровые инструменты** (17 шт.): `entity_create/get/list/update/delete`, `env_update/get`, `update_plot_rails`, `advance_time`, `schedule_trigger`, `calc` (с кубиками), `random_choice` (детерминированный), `rag_query/add`, `run_subagent`, `submit_plan/step`, `suggest_actions`. +- ✅ **Schema tools** — `schema_add_type/add_field/remove_field/modify_field` для world_editor. +- ✅ **Контекстный менеджер** — последние N сообщений + summary при превышении порога, деградация recent → rag → summary. +- ✅ **SSE-стриминг** — все долгие операции (world_builder, world_editor, orchestrator) отдают прогресс через SSE с `event:`/`data:`/`id:`, heartbeat, reconnect через `Last-Event-ID`. +- ✅ **Фронтенд** — React+TS+Vite+Tailwind, тёмная тема, i18n (en/ru), мобильный responsive, SSE-клиент с автопереподключением. -# 3. В логах backend найти строку: -# "ADMIN_SETUP_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxx" -# (или задать ADMIN_SETUP_TOKEN в .env вручную) - -# 4. Открыть http://localhost:5173 -# - Перейти на /admin/setup -# - Ввести токен → создать учётку администратора -# - Зайти в Admin Panel → настроить LLM endpoint - -# 5. Зарегистрировать обычного пользователя и начать создавать мир -``` +--- ## Архитектура ``` -┌────────────┐ SSE ┌────────────────────────────┐ -│ Frontend │ <──────► │ FastAPI backend │ -│ React/Vite │ HTTP │ - auth (JWT) │ -└────────────┘ │ - admin / settings │ - │ - worlds / sessions │ - │ - engine (orchestrator) │ - │ - tools (dice/rag/...) │ - │ - context manager │ - │ - trigger runner │ - └──┬──────────┬──────────┬───┘ - │ │ │ - ┌────────▼─┐ ┌─────▼────┐ ┌───▼─────┐ - │PostgreSQL│ │ Redis │ │ Qdrant │ - │ (data) │ │ (queues) │ │ (RAG) │ - └──────────┘ └──────────┘ └─────────┘ +┌──────────────────────────────────────────────────────────────┐ +│ Браузер (React 18 + Vite + TS + Tailwind + zustand) │ +└──────────────────────────┬───────────────────────────────────┘ + │ HTTP / SSE +┌──────────────────────────▼───────────────────────────────────┐ +│ FastAPI Backend (uvicorn) │ +│ ├─ app/api/ — роутеры (auth, worlds, sessions, ...) │ +│ ├─ app/engine/ — game_master, world_builder, editor │ +│ │ └─ tools/ — ToolRegistry + 17 game tools │ +│ ├─ app/core/ — llm, rag, embeddings, security, ... │ +│ ├─ app/models/ — SQLAlchemy ORM │ +│ ├─ app/prompts/ — системные промпты (en) │ +│ └─ app/schemas/ — Pydantic request/response │ +└──────┬─────────────────────────────────┬─────────────────────┘ + │ async SQLAlchemy │ httpx + qdrant-client +┌──────▼──────────────┐ ┌───────▼──────────────────────┐ +│ PostgreSQL 15 │ │ Qdrant 1.9 (векторный индекс)│ +│ (users, worlds, │ │ collections: entities, │ +│ entities, steps, │ │ story_entries │ +│ logs, ...) │ └──────────────────────────────┘ +└─────────────────────┘ ▲ + │ httpx (embeddings API) + ┌────────┴─────────────┐ + │ LLM Provider (any │ + │ OpenAI-compatible) │ + └──────────────────────┘ ``` -## Локальная разработка +--- + +## Быстрый старт + +### Опция 1: docker-compose (рекомендуется) ```bash -# Backend hot reload -docker compose up backend postgres redis qdrant +# 1. Скопировать .env.example в .env и отредактировать +cp .env.example .env +# Отредактируйте SECRET_KEY, ADMIN_SETUP_TOKEN, LLM_API_URL, LLM_API_KEY -# Frontend dev -docker compose up frontend +# 2. Поднять всё +docker compose up -d -# Миграции (автоматически при старте backend, но можно вручную) -docker compose exec backend python -m app.migrations.init_db +# 3. Зайти на http://localhost (frontend через nginx) +# Или http://localhost:5173 (frontend dev) / http://localhost:8000/api/docs (backend) ``` +При первом старте сервер напечатает в лог: +``` +=== AI-RPG Admin Setup === +No admin user yet. Open this URL in your browser: + /register/admin?token= +=========================== +``` + +Откройте `http://localhost/register/admin?token=<...>` и создайте первого админа. + +### Опция 2: локальный dev (backend + frontend раздельно) + +```bash +# Backend +cd /path/to/ai-rpg +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt + +# Запустить PostgreSQL и Qdrant (через docker compose up db qdrant) +docker compose up -d db qdrant + +# Применить миграции (создаёт все таблицы) +python -c "import asyncio; from app.db import get_engine; from app.migrations.versions.001_initial_schema import create_all_tables; asyncio.run(create_all_tables(get_engine()))" + +# Запустить backend +uvicorn app.main:app --reload --port 8000 + +# В другом терминале — frontend +cd frontend +npm install +npm run dev +# Откроется http://localhost:5173 +``` + +--- + +## Конфигурация LLM + +AI-RPG работает с **любым OpenAI-compatible API**: + +| Provider | `LLM_API_URL` | Пример `LLM_MODEL` | +|-----------------|-------------------------------------|------------------------------| +| OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` | +| Ollama (local) | `http://localhost:11434/v1` | `qwen2.5:7b-instruct` | +| LM Studio | `http://localhost:1234/v1` | `local-model` | +| vLLM | `http://localhost:8000/v1` | `Qwen/Qwen2.5-7B-Instruct` | +| OpenRouter | `https://openrouter.ai/api/v1` | `qwen/qwen-2.5-7b-instruct` | + +Если `LLM_API_URL` пуст — backend использует `MockLlmClient` (возвращает записанные replay-ответы). Это удобно для dev и тестов. + +### Embeddings + +Два провайдера: +- `offline_hash` (по умолчанию) — `HashEmbedder`, детерминированный bag-of-words + hash projection. Не делает HTTP-запросов, работает offline. Размерность 256. +- `openai` — OpenAI-compatible embeddings API. URL/key fallback на `llm.api_url`/`llm.api_key`, если `embeddings.api_url`/`embeddings.api_key` пустые. + +Кнопка «Авто-проба размерности» в админке (`POST /api/admin/test/embeddings/probe-dimension`) определяет реальную размерность модели и предлагает сохранить её в `embeddings.dimension`. + +--- + ## Структура проекта ``` ai-rpg/ +├── app/ # Backend (Python 3.12) +│ ├── api/ # FastAPI роутеры +│ │ ├── auth.py # /api/register, /api/auth/* +│ │ ├── worlds.py # /api/worlds +│ │ ├── sessions.py # /api/sessions/* (SSE streams) +│ │ ├── presets.py # /api/presets +│ │ ├── admin.py # /api/admin/* (settings, logs, test, upload) +│ │ └── misc.py # /api/health, /api/i18n +│ ├── core/ # Сквозные сервисы +│ │ ├── llm.py # LlmClient + MockLlmClient +│ │ ├── rag.py # RAG через Qdrant + PostgreSQL +│ │ ├── embeddings.py # HashEmbedder, OpenAIEmbedder +│ │ ├── qdrant_client.py # singleton AsyncQdrantClient +│ │ ├── security.py # JWT + bcrypt +│ │ ├── settings_service.py # settings table CRUD + seed +│ │ ├── state_validator.py # validate_state, apply_patch +│ │ ├── time_utils.py # GameTime, parse_delta, advance_time +│ │ └── logging.py # structlog setup +│ ├── engine/ # Игровой движок +│ │ ├── game_master.py # orchestrator (3 фазы) +│ │ ├── world_builder.py # flow создания мира +│ │ ├── world_editor.py # flow редактирования мира +│ │ ├── context.py # контекстный менеджер +│ │ ├── sse.py # SseEmitter +│ │ └── tools/ +│ │ ├── base.py # Tool, ToolRegistry, ToolContext, ToolResult +│ │ ├── game.py # 17 игровых инструментов +│ │ ├── schema_tools.py # 4 schema-инструмента +│ │ └── register_all.py # build_default_registry() +│ ├── models/ # SQLAlchemy ORM (10 таблиц) +│ ├── prompts/ +│ │ ├── registry.py # get_prompt(stage, language) +│ │ └── stages/ # 11 stage-промптов (en) +│ ├── schemas/ # Pydantic request/response +│ ├── migrations/ +│ │ ├── init_db.py # init_db(session) +│ │ ├── init_qdrant.py # re-export init_qdrant_collections +│ │ ├── seed.py # builtin presets (fantasy, sci-fi) +│ │ └── versions/001_initial_schema.py +│ ├── config.py # Settings (pydantic-settings) +│ ├── db.py # async engine + session factory +│ └── main.py # FastAPI app + lifespan +├── frontend/ # Frontend (React 18 + Vite + TS) +│ ├── src/ +│ │ ├── pages/ # 8 pages +│ │ ├── components/ +│ │ │ ├── ui/ # 9 primitives (Button, Card, Modal, ...) +│ │ │ ├── sessions/ # Chat, ToolCallBubble, ActionInput, ... +│ │ │ ├── worlds/ # WorldCard, WorldBuilder, WorldEditor +│ │ │ └── admin/ # SettingsPanel, LlmLogsTable, ... +│ │ ├── stores/ # zustand (auth, ui, worlds, session, toast) +│ │ ├── lib/ # api.ts, sse.ts, cn.ts +│ │ ├── i18n/ # en.json, ru.json +│ │ └── types/index.ts +│ ├── package.json +│ ├── vite.config.ts +│ ├── tsconfig.json +│ └── tailwind.config.js +├── tests/ # pytest +│ ├── unit/ # 65+ unit-тестов +│ └── integration/ # (placeholder) +├── deploy/ +│ ├── Dockerfile.backend +│ ├── Dockerfile.frontend +│ └── nginx.conf +├── docs/ +│ └── AI-RPG_TZ_TDD.md # оригинальное ТЗ ├── docker-compose.yml +├── requirements.txt +├── pytest.ini ├── .env.example -├── backend/ -│ ├── Dockerfile -│ ├── requirements.txt -│ └── app/ -│ ├── main.py # FastAPI entrypoint -│ ├── config.py # Settings -│ ├── api/ # Routers -│ ├── core/ # LLM client, security, rag -│ ├── engine/ # Game orchestrator -│ │ └── tools/ # LLM tool-call handlers -│ ├── models/ # SQLAlchemy models -│ ├── schemas/ # Pydantic schemas -│ ├── prompts/ # Bilingual prompt templates -│ └── workers/ # Background workers (triggers) -└── frontend/ - ├── Dockerfile - ├── package.json - └── src/ - ├── api/ # API client - ├── components/ # UI components - ├── pages/ # Page components - ├── store/ # Zustand stores - ├── hooks/ # React hooks - └── i18n/ # RU/EN translations +└── README.md ``` +--- + +## API обзор + +Полная OpenAPI-схема — на `http://localhost:8000/api/docs` (Swagger UI). + +### Ключевые эндпоинты + +| Метод | Путь | Назначение | +|---------|---------------------------------------------------|-----------------------------------------| +| `POST` | `/api/register` | Регистрация пользователя | +| `POST` | `/api/register/admin?token=` | Регистрация первого админа | +| `POST` | `/api/auth/login` | Логин по email/username → JWT | +| `GET` | `/api/auth/me` | Текущий профиль | +| `GET` | `/api/worlds` | Список миров пользователя | +| `POST` | `/api/worlds` | Создать мир → SSE URL для world_builder | +| `GET` | `/api/sessions/worlds/{id}/state` | Текущее состояние для play-страницы | +| `POST` | `/api/sessions/worlds/{id}/iterate` | Запустить orchestrator → SSE URL | +| `GET` | `/api/sessions/worlds/{id}/iterate/stream` | SSE-стрим итерации | +| `POST` | `/api/sessions/worlds/{id}/retry` | Повторить последний шаг | +| `POST` | `/api/sessions/worlds/{id}/rollback` | Откатить последний шаг | +| `GET` | `/api/admin/settings` | Все настройки (секреты замаскированы) | +| `PATCH` | `/api/admin/settings` | Обновить настройки | +| `GET` | `/api/admin/llm-logs?stage=&status_filter=&...` | Логи LLM-вызовов с фильтрами | +| `POST` | `/api/admin/test/llm?api_url=&api_key=&model=` | Проверка связности LLM | +| `POST` | `/api/admin/test/embeddings/probe-dimension` | Авто-проба размерности эмбеддингов | +| `POST` | `/api/admin/upload-icon` | Загрузить favicon/logo/og_image | +| `GET` | `/api/health` | Health-check (db, qdrant, llm, emb) | + +### SSE-события (orchestrator) + +| Event | Когда | +|----------------------|--------------------------------------------------| +| `phase_start` | Начало Phase 1/2/3 | +| `phase_end` | Конец фазы | +| `tool_call` | LLM вызвала инструмент | +| `llm_call_start/end` | Начало/конец LLM-вызова | +| `scene_chunk` | Streaming-чанк текста из Phase 2 | +| `scene_complete` | Полный текст сцены + delta_time | +| `suggested_actions` | 1-3 следующих действия | +| `trigger_fired` | Сработал отложенный триггер | +| `summary_generated` | Сгенерирован summary | +| `iteration_complete` | Полное завершение итерации | +| `done` | Успешное завершение стрима | +| `error` | Фатальная ошибка, стрим закрывается | + +--- + +## Тестирование + +```bash +# Backend unit-тесты +pytest tests/unit/ -v + +# С покрытием +pytest --cov=app --cov-report=term-missing tests/ + +# Frontend +cd frontend +npm run typecheck # tsc --noEmit +npm run lint # ESLint +npm run test # vitest +npm run build # production build +``` + +Покрытие unit-тестами: +- `app.core.time_utils` — парсинг/advance времени, дельты +- `app.core.security` — JWT, bcrypt, валидация пароля +- `app.core.state_validator` — валидация state/world, apply_patch (set/inc/dec/append/remove) +- `app.core.embeddings.HashEmbedder` — детерминизм, нормализация, размерность +- `app.core.llm.MockLlmClient` — replay, исчерпание, запись вызовов, стриминг +- `app.engine.tools.base.ToolRegistry` — регистрация, диспетч, unknown tool, исключения +- `app.engine.tools.game.CalcTool` — арифметика, кубики, переменные, ошибки +- `app.engine.tools.game.RandomChoiceTool` — детерминизм, веса +- `app.engine.sse.SseEmitter` — emit/done/error, ID, сериализация +- `app.prompts.registry` — все 11 stage-промптов валидны + +--- + +## Production-деплой + +См. `docs/AI-RPG_TZ_TDD.md` §13 (DevOps / Deployment) и §18.3 (Pre-deploy checklist). + +Ключевые моменты: +1. **SECRET_KEY** — сгенерировать через `python -c "import secrets; print(secrets.token_urlsafe(48))"`. +2. **ADMIN_SETUP_TOKEN** — задать в `.env` ИЛИ оставить пустым (тогда сгенерируется случайно при первом старте, напечатается в логах). +3. **HTTPS** — terminate TLS на nginx или на внешнем reverse-proxy. +4. **Backups** — ежедневно `pg_dump` + Qdrant snapshot в S3. +5. **Health-check** — `GET /api/health` должен вернуть 200 с `db:true, qdrant:true`. +6. **Мониторинг** — structlog пишет JSON в stdout, забирается любой log-агрегатор. + +--- + ## Лицензия -MIT +MIT — см. `LICENSE` (если отсутствует, предполагается MIT). + +--- + +## Changelog + +### v1.0.0 (2026-06-20) +- Первый release. Реализованы все 8 спринтов из ТЗ: + - Sprint 1: Foundation (docker-compose, БД, Qdrant, миграции, seed, health-check) + - Sprint 2: Auth + Admin (JWT, регистрация, админ-панель настроек, тесты LLM/embeddings) + - Sprint 3: World Builder (LLM-клиент, промпты, SSE-стрим генерации мира) + - Sprint 4: World Editor (чат-редактор, propose_changes, ask_user, optimistic lock) + - Sprint 5: Orchestrator (3 фазы, retry/rollback) + - Sprint 6: Frontend polish (i18n en/ru, тёмная тема, responsive, SSE reconnect) + - Sprint 7: RAG + Triggers + Summary + Context Manager + - Sprint 8: Production (README, метрики, тесты, сборка ZIP) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..178afa8 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,3 @@ +"""AI-RPG backend application package.""" + +__version__ = "1.0.0" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..dff53e5 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +"""API package.""" diff --git a/app/api/admin.py b/app/api/admin.py new file mode 100644 index 0000000..7cbb4ff --- /dev/null +++ b/app/api/admin.py @@ -0,0 +1,401 @@ +"""Admin API — settings, llm logs, users, stats, test endpoints, icon upload.""" + +from __future__ import annotations + +import os +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import require_admin +from app.config import get_settings +from app.core.embeddings import ( + HashEmbedder, + OpenAIEmbedder, + build_hash_embedder, + build_openai_embedder, +) +from app.core.llm import LlmClient +from app.core.logging import get_logger +from app.core.qdrant_client import init_qdrant_collections +from app.core.rag import reset_embedder_cache +from app.core.settings_service import ( + DEFAULT_SETTINGS, + SECRET_KEYS, + get_all_settings, + mask_secret, + set_setting, +) +from app.db import get_db +from app.models import LlmCallLog, User +from app.schemas import LlmLogDetail, LlmLogOut, SettingsPatchRequest + +_logger = get_logger(__name__) + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +# --------------------------------------------------------------------------- # +# Settings +# --------------------------------------------------------------------------- # +@router.get("/settings") +async def get_settings_endpoint( + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_admin), +) -> dict: + settings = await get_all_settings(db) + # Mask secrets + out = {k: mask_secret(k, v) for k, v in settings.items()} + return {"settings": out, "descriptions": {k: s["description"] for k, s in DEFAULT_SETTINGS.items()}} + + +@router.patch("/settings") +async def patch_settings_endpoint( + body: SettingsPatchRequest, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_admin), +) -> dict: + updated = {} + for k, v in body.settings.items(): + # Don't update secret keys if the masked value was sent back unchanged + if k in SECRET_KEYS and isinstance(v, str) and ("…" in v or v == "****"): + continue + await set_setting(db, k, v) + updated[k] = mask_secret(k, v) + # Clear embedder cache so new settings take effect + reset_embedder_cache() + return {"updated": updated} + + +# --------------------------------------------------------------------------- # +# LLM logs +# --------------------------------------------------------------------------- # +@router.get("/llm-logs") +async def list_llm_logs( + world_id: uuid.UUID | None = None, + stage: str | None = None, + status_filter: str | None = None, + page: int = 1, + per_page: int = 50, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_admin), +) -> dict: + stmt = select(LlmCallLog) + if world_id: + stmt = stmt.where(LlmCallLog.world_id == world_id) + if stage: + stmt = stmt.where(LlmCallLog.stage == stage) + if status_filter: + stmt = stmt.where(LlmCallLog.status == status_filter) + total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one() + stmt = stmt.order_by(LlmCallLog.created_at.desc()).offset((page - 1) * per_page).limit(per_page) + rows = (await db.execute(stmt)).scalars().all() + return { + "items": [LlmLogOut.model_validate(r).model_dump(mode="json") for r in rows], + "total": total, "page": page, "per_page": per_page, + } + + +@router.get("/llm-logs/{log_id}", response_model=LlmLogDetail) +async def get_llm_log( + log_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_admin), +) -> LlmCallLog: + log = ( + await db.execute(select(LlmCallLog).where(LlmCallLog.id == log_id)) + ).scalar_one_or_none() + if log is None: + raise HTTPException(404, "not_found") + return log + + +# --------------------------------------------------------------------------- # +# Users +# --------------------------------------------------------------------------- # +@router.get("/users") +async def list_users( + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_admin), +) -> dict: + rows = (await db.execute(select(User).order_by(User.created_at.desc()))).scalars().all() + return {"items": [ + {"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(), "last_login_at": u.last_login_at.isoformat() if u.last_login_at else None} + for u in rows + ]} + + +@router.patch("/users/{user_id}") +async def patch_user( + user_id: uuid.UUID, + body: dict, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_admin), +) -> dict: + user = ( + await db.execute(select(User).where(User.id == user_id)) + ).scalar_one_or_none() + if user is None: + raise HTTPException(404, "not_found") + if "is_admin" in body: + user.is_admin = bool(body["is_admin"]) + if "is_active" in body: + user.is_active = bool(body["is_active"]) + await db.commit() + return {"id": str(user.id), "is_admin": user.is_admin, "is_active": user.is_active} + + +# --------------------------------------------------------------------------- # +# Stats +# --------------------------------------------------------------------------- # +@router.get("/stats") +async def stats( + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_admin), +) -> dict: + from app.models import Step, World + + users_count = (await db.execute(select(func.count(User.id)))).scalar_one() + worlds_count = (await db.execute(select(func.count(World.id)))).scalar_one() + steps_count = (await db.execute(select(func.count(Step.id)))).scalar_one() + avg_latency = ( + await db.execute(select(func.avg(LlmCallLog.latency_ms))) + ).scalar_one() + return { + "users": users_count, + "worlds": worlds_count, + "steps": steps_count, + "avg_llm_latency_ms": float(avg_latency) if avg_latency else 0, + } + + +# --------------------------------------------------------------------------- # +# Test endpoints — LLM, embeddings, embeddings probe dimension +# --------------------------------------------------------------------------- # +@router.post("/test/llm") +async def test_llm( + api_url: str | None = None, + api_key: str | None = None, + model: str | None = None, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_admin), +) -> dict: + settings = await get_all_settings(db) + api_url = api_url or settings.get("llm.api_url", "") + api_key = api_key or settings.get("llm.api_key", "") + model = model or settings.get("llm.model", "") + if not api_url: + return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"}, + "elapsed_ms": 0} + client = LlmClient(api_url=api_url, api_key=api_key, model=model, timeout=15.0, max_retries=1) + start = time.monotonic() + try: + resp = await client.complete( + stage="test_llm", + messages=[{"role": "user", "content": "Reply with exactly: OK"}], + temperature=0.0, max_tokens=10, + session=db, + ) + elapsed = int((time.monotonic() - start) * 1000) + return { + "ok": True, "response": resp["message"].get("content", "").strip(), + "model": model, "elapsed_ms": elapsed, + "prompt_tokens": resp.get("prompt_tokens"), "completion_tokens": resp.get("completion_tokens"), + } + except Exception as e: # noqa: BLE001 + elapsed = int((time.monotonic() - start) * 1000) + return {"ok": False, "error": {"code": "connection_failed", "message": str(e)}, + "elapsed_ms": elapsed} + + +@router.post("/test/llm-tools") +async def test_llm_tools( + api_url: str | None = None, + api_key: str | None = None, + model: str | None = None, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_admin), +) -> dict: + settings = await get_all_settings(db) + api_url = api_url or settings.get("llm.api_url", "") + api_key = api_key or settings.get("llm.api_key", "") + model = model or settings.get("llm.model", "") + if not api_url: + return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"}, + "elapsed_ms": 0, "has_tool_calls": False} + client = LlmClient(api_url=api_url, api_key=api_key, model=model, timeout=15.0, max_retries=1) + start = time.monotonic() + try: + tools = [{ + "type": "function", + "function": { + "name": "calc", + "description": "Evaluate a math expression", + "parameters": { + "type": "object", + "required": ["expression"], + "properties": {"expression": {"type": "string"}}, + }, + }, + }] + resp = await client.complete( + stage="test_llm_tools", + messages=[{"role": "user", "content": "What is 2+2? Use the calc tool."}], + tools=tools, temperature=0.0, max_tokens=100, + session=db, + ) + elapsed = int((time.monotonic() - start) * 1000) + tcs = resp["message"].get("tool_calls") or [] + return { + "ok": True, "tool_calls": tcs, "has_tool_calls": bool(tcs), "elapsed_ms": elapsed, + } + except Exception as e: # noqa: BLE001 + elapsed = int((time.monotonic() - start) * 1000) + return {"ok": False, "error": {"code": "connection_failed", "message": str(e)}, + "elapsed_ms": elapsed, "has_tool_calls": False} + + +@router.post("/test/embeddings") +async def test_embeddings( + api_url: str | None = None, + api_key: str | None = None, + model: str | None = None, + provider: str | None = None, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_admin), +) -> dict: + settings = await get_all_settings(db) + provider = provider or settings.get("embeddings.provider", "offline_hash") + start = time.monotonic() + try: + if provider == "offline_hash": + emb = build_hash_embedder(int(settings.get("embeddings.dimension", 256))) + vecs = await emb.embed(["hello world"]) + elapsed = int((time.monotonic() - start) * 1000) + return { + "ok": True, "dimension": emb.dimension, "model": "offline_hash", + "first_5_values": vecs[0][:5] if vecs else [], "elapsed_ms": elapsed, + } + api_url = api_url or settings.get("embeddings.api_url") or settings.get("llm.api_url", "") + api_key = api_key or settings.get("embeddings.api_key") or settings.get("llm.api_key", "") + model = model or settings.get("embeddings.model", "") + if not api_url: + return {"ok": False, "error": {"code": "not_configured", "message": "no api_url"}, + "elapsed_ms": 0} + emb = build_openai_embedder( + api_url=api_url, api_key=api_key, model=model, + dimension=int(settings.get("embeddings.dimension", 1536)), + timeout=15.0, + ) + vecs = await emb.embed(["hello world"]) + elapsed = int((time.monotonic() - start) * 1000) + return { + "ok": True, "dimension": len(vecs[0]) if vecs else 0, "model": model, + "first_5_values": vecs[0][:5] if vecs else [], "elapsed_ms": elapsed, + } + except Exception as e: # noqa: BLE001 + elapsed = int((time.monotonic() - start) * 1000) + return {"ok": False, "error": {"code": "connection_failed", "message": str(e)}, + "elapsed_ms": elapsed} + + +@router.post("/test/embeddings/probe-dimension") +async def probe_dimension( + api_url: str | None = None, + api_key: str | None = None, + model: str | None = None, + provider: str | None = None, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_admin), +) -> dict: + settings = await get_all_settings(db) + provider = provider or settings.get("embeddings.provider", "offline_hash") + start = time.monotonic() + try: + if provider == "offline_hash": + return { + "ok": True, + "dimension": int(settings.get("embeddings.dimension", 256)), + "elapsed_ms": 0, + } + api_url = api_url or settings.get("embeddings.api_url") or settings.get("llm.api_url", "") + api_key = api_key or settings.get("embeddings.api_key") or settings.get("llm.api_key", "") + model = model or settings.get("embeddings.model", "") + emb = build_openai_embedder( + api_url=api_url, api_key=api_key, model=model, + dimension=int(settings.get("embeddings.dimension", 1536)), + timeout=15.0, + ) + dim = await emb.probe_dimension() + elapsed = int((time.monotonic() - start) * 1000) + return {"ok": True, "dimension": dim, "elapsed_ms": elapsed} + except Exception as e: # noqa: BLE001 + elapsed = int((time.monotonic() - start) * 1000) + return {"ok": False, "error": {"code": "probe_failed", "message": str(e)}, + "elapsed_ms": elapsed} + + +@router.post("/embeddings/recreate-collections") +async def recreate_collections( + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_admin), +) -> dict: + """Drop and recreate Qdrant collections with the current embedding dimension.""" + from app.core.qdrant_client import get_qdrant_client + + settings = await get_all_settings(db) + cfg = get_settings() + prefix = cfg.qdrant_collection_prefix or "" + client = get_qdrant_client() + existing = {c.name for c in (await client.get_collections()).collections} + dropped = [] + for name in (f"{prefix}entities", f"{prefix}story_entries"): + if name in existing: + await client.delete_collection(name) + dropped.append(name) + result = await init_qdrant_collections(int(settings.get("embeddings.dimension", 256))) + return {"dropped": dropped, "created": result["created"], "dimension": result["dimension"]} + + +# --------------------------------------------------------------------------- # +# Icon upload +# --------------------------------------------------------------------------- # +@router.post("/upload-icon") +async def upload_icon( + file: UploadFile = File(...), + kind: str = Form("favicon"), + _user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> dict: + cfg = get_settings() + if kind not in ("favicon", "logo", "og_image"): + raise HTTPException(400, "kind must be one of: favicon, logo, og_image") + contents = await file.read() + if len(contents) > cfg.max_upload_size_bytes: + raise HTTPException(413, "file too large (max 1MB)") + # Validate extension + allowed_exts = {".png", ".svg", ".jpg", ".jpeg", ".webp", ".ico"} + ext = Path(file.filename or "").suffix.lower() + if ext not in allowed_exts: + raise HTTPException(400, f"unsupported extension: {ext}") + assets_dir = Path(cfg.assets_dir) + assets_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + fname = f"{kind}_{ts}{ext}" + out_path = assets_dir / fname + out_path.write_bytes(contents) + url = f"/static/assets/{fname}" + setting_key = { + "favicon": "ui.favicon_url", + "logo": "ui.logo_url", + "og_image": "ui.og_image_url", + }[kind] + await set_setting(db, setting_key, url) + return {"ok": True, "kind": kind, "url": url, "size_bytes": len(contents)} diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..b4a49de --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,224 @@ +"""Auth endpoints: register, register/admin, login, refresh, logout, me.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy import func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user +from app.core.security import ( + create_access_token, + create_refresh_token, + decode_token, + hash_password, + validate_password_strength, + verify_password, +) +from app.core.settings_service import get_admin_setup_token +from app.db import get_db +from app.models import User +from app.schemas import ( + AdminRegisterRequest, + LoginRequest, + RegisterRequest, + TokenResponse, + UserPublic, +) + +router = APIRouter(prefix="/api", tags=["auth"]) + + +async def _check_first_admin(db: AsyncSession) -> bool: + """Return True if at least one admin exists.""" + cnt = ( + await db.execute(select(func.count(User.id)).where(User.is_admin.is_(True))) + ).scalar_one() + return cnt > 0 + + +@router.post("/register", response_model=UserPublic, status_code=status.HTTP_201_CREATED) +async def register( + body: RegisterRequest, + db: AsyncSession = Depends(get_db), +) -> User: + """Register a regular user. Only allowed if at least one admin already exists.""" + has_admin = await _check_first_admin(db) + if not has_admin: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "no_admin_yet_use_admin_register", + ) + + existing = ( + await db.execute( + select(User).where( + or_(User.email == body.email, User.username == body.username) + ) + ) + ).scalar_one_or_none() + if existing is not None: + if existing.email == body.email: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "email_already_exists") + raise HTTPException(status.HTTP_400_BAD_REQUEST, "username_already_exists") + + errors = validate_password_strength(body.password) + if errors: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=errors[0]) + + user = User( + email=body.email, + username=body.username, + password_hash=hash_password(body.password), + is_admin=False, + is_active=True, + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +@router.post( + "/register/admin", + response_model=UserPublic, + status_code=status.HTTP_201_CREATED, +) +async def register_admin( + body: AdminRegisterRequest, + db: AsyncSession = Depends(get_db), +) -> User: + """Register the first admin user. Requires a valid setup token.""" + has_admin = await _check_first_admin(db) + if has_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, "admin_already_exists") + + expected_token = await get_admin_setup_token(db) + if body.token != expected_token: + raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid_admin_token") + + existing = ( + await db.execute( + select(User).where( + or_(User.email == body.email, User.username == body.username) + ) + ) + ).scalar_one_or_none() + if existing is not None: + if existing.email == body.email: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "email_already_exists") + raise HTTPException(status.HTTP_400_BAD_REQUEST, "username_already_exists") + + errors = validate_password_strength(body.password) + if errors: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=errors[0]) + + user = User( + email=body.email, + username=body.username, + password_hash=hash_password(body.password), + is_admin=True, + is_active=True, + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +@router.post("/auth/login", response_model=TokenResponse) +async def login( + body: LoginRequest, + db: AsyncSession = Depends(get_db), +) -> TokenResponse: + """Login by email OR username. Returns access + refresh JWTs.""" + stmt = select(User).where( + or_(User.email == body.login, User.username == body.login) + ) + user = (await db.execute(stmt)).scalar_one_or_none() + if user is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid_credentials") + if not verify_password(body.password, user.password_hash): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid_credentials") + if not user.is_active: + raise HTTPException(status.HTTP_403_FORBIDDEN, "account_disabled") + + user.last_login_at = datetime.now(timezone.utc) + await db.commit() + + access = create_access_token(user.id, extra_claims={"is_admin": user.is_admin}) + refresh = create_refresh_token(user.id) + return TokenResponse( + access_token=access, + refresh_token=refresh, + token_type="bearer", + expires_in=60 * 24, + user=UserPublic.model_validate(user), + ) + + +@router.post("/auth/refresh", response_model=TokenResponse) +async def refresh_token( + db: AsyncSession = Depends(get_db), + token: str = ..., +) -> TokenResponse: + """Exchange a refresh token for a new access + refresh pair. + + The token is passed in the request body as `{refresh_token: "..."}`. + """ + raise NotImplementedError("Implemented below via RefreshRequest body") + + +from pydantic import BaseModel # noqa: E402 + + +class RefreshRequest(BaseModel): + refresh_token: str + + +@router.post("/auth/refresh", response_model=TokenResponse, name="refresh_real") +async def refresh_real( + body: RefreshRequest, + db: AsyncSession = Depends(get_db), +) -> TokenResponse: + try: + payload = decode_token(body.refresh_token) + except Exception as e: # noqa: BLE001 + raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Invalid refresh token: {e}") + if payload.get("type") != "refresh": + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not a refresh token") + user_id = uuid.UUID(payload["sub"]) + user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none() + if user is None or not user.is_active: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found or disabled") + + access = create_access_token(user.id, extra_claims={"is_admin": user.is_admin}) + new_refresh = create_refresh_token(user.id) + return TokenResponse( + access_token=access, + refresh_token=new_refresh, + token_type="bearer", + expires_in=60 * 24, + user=UserPublic.model_validate(user), + ) + + +# Remove the placeholder earlier /auth/refresh route so only the real one stays. +_refresh_routes = [r for r in router.routes if getattr(r, "path", "") == "/api/auth/refresh"] +if len(_refresh_routes) > 1: + router.routes.remove(_refresh_routes[0]) + + +@router.post("/auth/logout", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +async def logout() -> Response: + """Stateless logout — client drops the tokens.""" + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/auth/me", response_model=UserPublic) +async def me(current: User = Depends(get_current_user)) -> User: + """Return the current user's profile.""" + return current diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..785109c --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,64 @@ +"""Shared API dependencies: current user, admin guard, db session, settings.""" + +from __future__ import annotations + +import uuid +from typing import Any + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import decode_token +from app.core.settings_service import get_all_settings +from app.db import get_db +from app.models import User + +_bearer = HTTPBearer(auto_error=False) + + +async def get_current_user( + creds: HTTPAuthorizationCredentials | None = Depends(_bearer), + db: AsyncSession = Depends(get_db), +) -> User: + """Resolve the JWT bearer token to a User row. + + Raises 401 on missing/invalid/expired token. + """ + if creds is None or creds.scheme.lower() != "bearer": + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing bearer token") + try: + payload = decode_token(creds.credentials) + except Exception as e: # noqa: BLE001 + raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Invalid token: {e}") + if payload.get("type") != "access": + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wrong token type") + user_id_str = payload.get("sub") + if not user_id_str: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token missing sub") + try: + user_id = uuid.UUID(user_id_str) + except ValueError: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid user id in token") + + user = ( + await db.execute(select(User).where(User.id == user_id)) + ).scalar_one_or_none() + if user is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found") + if not user.is_active: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Account disabled") + return user + + +async def require_admin(user: User = Depends(get_current_user)) -> User: + """Require an admin user.""" + if not user.is_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin only") + return user + + +async def get_settings_dict(db: AsyncSession = Depends(get_db)) -> dict[str, Any]: + """FastAPI dependency: returns the full settings dict.""" + return await get_all_settings(db) diff --git a/app/api/misc.py b/app/api/misc.py new file mode 100644 index 0000000..70b0f84 --- /dev/null +++ b/app/api/misc.py @@ -0,0 +1,74 @@ +"""Misc endpoints: health, i18n.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text + +from app import __version__ +from app.config import get_settings +from app.core.embeddings import HashEmbedder +from app.core.logging import get_logger +from app.core.qdrant_client import ping_qdrant +from app.db import get_db +from app.schemas import HealthResponse + +_logger = get_logger(__name__) + +router = APIRouter(prefix="/api", tags=["misc"]) + + +@router.get("/health", response_model=HealthResponse) +async def health(db: AsyncSession = Depends(get_db)) -> HealthResponse: + """Health-check endpoint — no auth required.""" + db_ok = False + qdrant_ok = False + try: + await db.execute(text("SELECT 1")) + db_ok = True + except Exception as e: # noqa: BLE001 + _logger.warning("health_db_failed", error=str(e)) + try: + qdrant_ok = await ping_qdrant() + except Exception as e: # noqa: BLE001 + _logger.warning("health_qdrant_failed", error=str(e)) + + # LLM health: we treat it as "true" only if api_url is set AND we can avoid a real call. + # For the basic health probe we don't make any LLM calls — return True iff api_url is configured. + cfg = get_settings() + llm_ok = bool(cfg.llm_api_url) + + # Embeddings: True if HashEmbedder works (always does) or if openai provider configured + embeddings_ok = True + if cfg.embeddings_provider == "offline_hash": + try: + embedder = HashEmbedder(dimension=cfg.embeddings_dimension) + _ = await embedder.embed(["ping"]) + except Exception: + embeddings_ok = False + + status_str = "ok" if (db_ok and qdrant_ok) else "degraded" + return HealthResponse( + status=status_str, + db=db_ok, + qdrant=qdrant_ok, + llm=llm_ok, + embeddings=embeddings_ok, + version=__version__, + ) + + +@router.get("/i18n/{lang}") +async def i18n(lang: str) -> dict: + """Return translation JSON for the given language. + + Backend only knows the en/ru bundles used by the frontend; we serve them + statically from the frontend's `public/i18n/` folder in production, but + this endpoint is useful for hot-reloading in dev. + """ + if lang not in ("en", "ru"): + return {"error": "unsupported language"} + # The frontend owns the bundles; this endpoint returns an empty dict + # (the frontend fetches `/i18n/{lang}.json` as a static asset). + return {"language": lang} diff --git a/app/api/presets.py b/app/api/presets.py new file mode 100644 index 0000000..ad4218c --- /dev/null +++ b/app/api/presets.py @@ -0,0 +1,125 @@ +"""Presets API — CRUD for world presets.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user +from app.db import get_db +from app.models import User, WorldPreset +from app.schemas import PresetCreateRequest, PresetFull, PresetSummary + +router = APIRouter(prefix="/api/presets", tags=["presets"]) + + +@router.get("", response_model=dict) +async def list_presets( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +) -> dict: + """List public presets + current user's presets.""" + rows = ( + await db.execute( + select(WorldPreset).where( + or_( + WorldPreset.is_public.is_(True), + WorldPreset.owner_id == user.id, + ) + ).order_by(WorldPreset.created_at.desc()) + ) + ).scalars().all() + return {"items": [PresetSummary.model_validate(r).model_dump() for r in rows]} + + +@router.post("", response_model=PresetFull, status_code=status.HTTP_201_CREATED) +async def create_preset( + body: PresetCreateRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +) -> WorldPreset: + if not user.is_admin: + raise HTTPException(403, "admin_only") + preset = WorldPreset( + owner_id=user.id, + name=body.name, + description=body.description, + language=body.language, + rules=body.rules, + time_schema=body.time_schema, + schemas=body.schemas, + environment_schema=body.environment_schema, + environment_initial=body.environment_initial, + is_public=body.is_public, + status="ready", + ) + db.add(preset) + await db.commit() + await db.refresh(preset) + return preset + + +@router.get("/{preset_id}", response_model=PresetFull) +async def get_preset( + preset_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +) -> WorldPreset: + preset = ( + await db.execute(select(WorldPreset).where(WorldPreset.id == preset_id)) + ).scalar_one_or_none() + if preset is None: + raise HTTPException(404, "not_found") + if not preset.is_public and preset.owner_id != user.id and not user.is_admin: + raise HTTPException(403, "not_accessible") + return preset + + +@router.patch("/{preset_id}", response_model=PresetFull) +async def update_preset( + preset_id: uuid.UUID, + body: PresetCreateRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +) -> WorldPreset: + preset = ( + await db.execute(select(WorldPreset).where(WorldPreset.id == preset_id)) + ).scalar_one_or_none() + if preset is None: + raise HTTPException(404, "not_found") + if preset.owner_id != user.id and not user.is_admin: + raise HTTPException(403, "not_owner") + preset.name = body.name + preset.description = body.description + preset.language = body.language + preset.rules = body.rules + preset.time_schema = body.time_schema + preset.schemas = body.schemas + preset.environment_schema = body.environment_schema + preset.environment_initial = body.environment_initial + preset.is_public = body.is_public + preset.version += 1 + await db.commit() + await db.refresh(preset) + return preset + + +@router.delete("/{preset_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +async def delete_preset( + preset_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +) -> Response: + preset = ( + await db.execute(select(WorldPreset).where(WorldPreset.id == preset_id)) + ).scalar_one_or_none() + if preset is None: + raise HTTPException(404, "not_found") + if preset.owner_id != user.id and not user.is_admin: + raise HTTPException(403, "not_owner") + preset.status = "archived" + await db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/api/sessions.py b/app/api/sessions.py new file mode 100644 index 0000000..186c2e6 --- /dev/null +++ b/app/api/sessions.py @@ -0,0 +1,348 @@ +"""Sessions API — state retrieval, orchestrator iterate stream, world_builder/editor streams, retry/rollback.""" + +from __future__ import annotations + +import json +import urllib.parse +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from fastapi.responses import StreamingResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user, get_settings_dict +from app.core.llm import LlmClient, MockLlmClient +from app.core.logging import get_logger +from app.db import get_db +from app.engine.game_master import run_iteration +from app.engine.sse import SseEmitter +from app.engine.world_builder import run_world_builder +from app.engine.world_editor import run_world_editor +from app.models import Entity, Step, World, WorldPreset +from app.schemas import AnswerRequest, IterateRequest + +_logger = get_logger(__name__) + +router = APIRouter(prefix="/api/sessions", tags=["sessions"]) + + +def _llm_factory(settings: dict) -> LlmClient | MockLlmClient: + api_url = settings.get("llm.api_url", "") + if not api_url: + return MockLlmClient() + return LlmClient.from_settings(settings) + + +async def _load_world(db: AsyncSession, world_id: uuid.UUID, user) -> World: + world = ( + await db.execute(select(World).where(World.id == world_id)) + ).scalar_one_or_none() + if world is None: + raise HTTPException(404, "not_found") + if world.owner_id != user.id and not user.is_admin: + raise HTTPException(403, "not_owner") + return world + + +# --------------------------------------------------------------------------- # +# State retrieval +# --------------------------------------------------------------------------- # +@router.get("/worlds/{world_id}/state") +async def get_state( + world_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +) -> dict: + """Return current session state for the play page.""" + world = await _load_world(db, world_id, user) + recent = ( + await db.execute( + select(Step) + .where(Step.world_id == world.id, Step.deleted_at.is_(None)) + .order_by(Step.sequence_number.desc()) + .limit(10) + ) + ).scalars().all() + recent_steps = [ + { + "id": str(s.id), "sequence_number": s.sequence_number, + "player_action": s.player_action, "scene_text": s.scene_text, + "suggested_actions": s.suggested_actions, "created_at": s.created_at.isoformat(), + } + for s in reversed(recent) + ] + next_actions = recent_steps[-1]["suggested_actions"] if recent_steps else [] + if world.intro_scene and not recent_steps: + next_actions = [] + return { + "world": { + "id": str(world.id), "name": world.name, "current_time": world.current_time, + "language": world.language, "intro_scene": world.intro_scene, + }, + "environment": world.environment, + "recent_steps": recent_steps, + "next_actions": next_actions, + } + + +# --------------------------------------------------------------------------- # +# World builder stream (SSE) +# --------------------------------------------------------------------------- # +@router.get("/worlds/{world_id}/builder/stream") +async def builder_stream( + world_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), + settings: dict = Depends(get_settings_dict), +) -> StreamingResponse: + world = await _load_world(db, world_id, user) + preset: WorldPreset | None = None + if world.preset_id: + preset = ( + await db.execute(select(WorldPreset).where(WorldPreset.id == world.preset_id)) + ).scalar_one_or_none() + emitter = SseEmitter() + player_name = (world.environment or {}).get("player", {}).get("name", "Hero") + notes = world.description + llm = _llm_factory(settings) + + async def run_bg(): + async with _session_scope() as bg_db: + # Reload world in this session + bg_world = ( + await bg_db.execute(select(World).where(World.id == world.id)) + ).scalar_one() + await run_world_builder( + db=bg_db, world=bg_world, player_name=player_name, notes=notes, + llm=llm, sse=emitter, preset=preset, + ) + + import asyncio + + task = asyncio.create_task(run_bg()) + + async def gen(): + try: + async for evt in emitter.stream(): + yield _format_sse(evt) + finally: + await task + + return StreamingResponse( + gen(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +# --------------------------------------------------------------------------- # +# World editor stream (SSE) +# --------------------------------------------------------------------------- # +@router.get("/worlds/{world_id}/editor/stream") +async def editor_stream( + world_id: uuid.UUID, + instruction: str = Query(...), + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), + settings: dict = Depends(get_settings_dict), +) -> StreamingResponse: + world = await _load_world(db, world_id, user) + emitter = SseEmitter() + llm = _llm_factory(settings) + + async def run_bg(): + async with _session_scope() as bg_db: + bg_world = ( + await bg_db.execute(select(World).where(World.id == world.id)) + ).scalar_one() + await run_world_editor( + db=bg_db, world=bg_world, instruction=instruction, llm=llm, sse=emitter, + ) + + import asyncio + + task = asyncio.create_task(run_bg()) + + async def gen(): + try: + async for evt in emitter.stream(): + yield _format_sse(evt) + finally: + await task + + return StreamingResponse( + gen(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +# --------------------------------------------------------------------------- # +# Orchestrator iterate +# --------------------------------------------------------------------------- # +@router.post("/worlds/{world_id}/iterate", response_model=dict, status_code=status.HTTP_202_ACCEPTED) +async def iterate( + world_id: uuid.UUID, + body: IterateRequest, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), + settings: dict = Depends(get_settings_dict), +) -> dict: + world = await _load_world(db, world_id, user) + if world.status != "ready": + raise HTTPException(422, "world_not_ready") + # Compute next sequence number + last_seq = ( + await db.execute( + select(Step.sequence_number) + .where(Step.world_id == world.id, Step.deleted_at.is_(None)) + .order_by(Step.sequence_number.desc()) + .limit(1) + ) + ).scalar_one_or_none() + next_seq = (last_seq or 0) + 1 + step = Step( + world_id=world.id, + sequence_number=next_seq, + player_action=body.action, + status="pending", + ) + db.add(step) + await db.commit() + await db.refresh(step) + return { + "stream_url": f"/api/sessions/worlds/{world.id}/iterate/stream?step_id={step.id}", + "step_id": str(step.id), + } + + +@router.get("/worlds/{world_id}/iterate/stream") +async def iterate_stream( + world_id: uuid.UUID, + step_id: uuid.UUID = Query(...), + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), + settings: dict = Depends(get_settings_dict), +) -> StreamingResponse: + world = await _load_world(db, world_id, user) + step = ( + await db.execute(select(Step).where(Step.id == step_id, Step.world_id == world.id)) + ).scalar_one_or_none() + if step is None: + raise HTTPException(404, "step not found") + emitter = SseEmitter() + llm = _llm_factory(settings) + + async def run_bg(): + async with _session_scope() as bg_db: + bg_world = ( + await bg_db.execute(select(World).where(World.id == world.id)) + ).scalar_one() + bg_step = ( + await bg_db.execute(select(Step).where(Step.id == step.id)) + ).scalar_one() + await run_iteration(db=bg_db, world=bg_world, step=bg_step, llm=llm, sse=emitter) + + import asyncio + + task = asyncio.create_task(run_bg()) + + async def gen(): + try: + async for evt in emitter.stream(): + yield _format_sse(evt) + finally: + await task + + return StreamingResponse( + gen(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +# --------------------------------------------------------------------------- # +# Retry / rollback +# --------------------------------------------------------------------------- # +@router.post("/worlds/{world_id}/retry", response_model=dict) +async def retry_last( + world_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +) -> dict: + """Soft-delete the last step and create a new one with the same action.""" + world = await _load_world(db, world_id, user) + last = ( + await db.execute( + select(Step) + .where(Step.world_id == world.id, Step.deleted_at.is_(None)) + .order_by(Step.sequence_number.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if last is None: + raise HTTPException(404, "no_step_to_retry") + last.deleted_at = datetime.now(timezone.utc) + new_step = Step( + world_id=world.id, + sequence_number=last.sequence_number + 1, + player_action=last.player_action, + status="pending", + ) + db.add(new_step) + await db.commit() + await db.refresh(new_step) + return { + "step_id": str(new_step.id), + "stream_url": f"/api/sessions/worlds/{world.id}/iterate/stream?step_id={new_step.id}", + } + + +@router.post("/worlds/{world_id}/rollback", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +async def rollback_last( + world_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +) -> Response: + """Soft-delete the last step.""" + world = await _load_world(db, world_id, user) + last = ( + await db.execute( + select(Step) + .where(Step.world_id == world.id, Step.deleted_at.is_(None)) + .order_by(Step.sequence_number.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if last is None: + raise HTTPException(404, "no_step_to_rollback") + last.deleted_at = datetime.now(timezone.utc) + await db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # +def _format_sse(evt: dict[str, str]) -> str: + """Format an SSE event dict into the wire format.""" + lines = [] + if "id" in evt: + lines.append(f"id: {evt['id']}") + if "event" in evt: + lines.append(f"event: {evt['event']}") + if "data" in evt: + # Split multi-line data + for chunk in evt["data"].split("\n"): + lines.append(f"data: {chunk}") + lines.append("") + lines.append("") + return "\n".join(lines) + + +async def _session_scope(): + """Open a fresh DB session for the background task.""" + from app.db import get_sessionmaker + + sm = get_sessionmaker() + async with sm() as s: + yield s diff --git a/app/api/worlds.py b/app/api/worlds.py new file mode 100644 index 0000000..8ba32ca --- /dev/null +++ b/app/api/worlds.py @@ -0,0 +1,182 @@ +"""Worlds API — CRUD + world_builder stream + world_editor stream.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi.responses import StreamingResponse +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user, get_settings_dict +from app.core.llm import LlmClient, MockLlmClient +from app.core.logging import get_logger +from app.core.settings_service import get_all_settings +from app.db import get_db +from app.engine.sse import SseEmitter +from app.engine.world_builder import run_world_builder +from app.engine.world_editor import run_world_editor +from app.models import User, World, WorldPreset +from app.schemas import ( + WorldCreateRequest, + WorldEditRequest, + WorldFull, + WorldPatchRequest, + WorldSummary, +) + +_logger = get_logger(__name__) + +router = APIRouter(prefix="/api/worlds", tags=["worlds"]) + + +def _llm_factory(settings: dict) -> LlmClient | MockLlmClient: + """Construct an LLM client. Falls back to MockLlmClient if no api_url configured.""" + api_url = settings.get("llm.api_url", "") + if not api_url: + _logger.warning("llm_not_configured_using_mock") + return MockLlmClient() + return LlmClient.from_settings(settings) + + +@router.get("", response_model=dict) +async def list_worlds( + page: int = 1, + per_page: int = 20, + status_filter: str | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +) -> dict: + """List the current user's worlds.""" + stmt = select(World).where(World.owner_id == user.id) + if status_filter and status_filter != "all": + stmt = stmt.where(World.status == status_filter) + total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one() + stmt = stmt.order_by(World.last_played_at.desc().nullslast(), World.created_at.desc()) + stmt = stmt.offset((page - 1) * per_page).limit(per_page) + rows = (await db.execute(stmt)).scalars().all() + items = [] + for w in rows: + env = w.environment or {} + player = env.get("player") if isinstance(env, dict) else None + pname = player.get("name") if isinstance(player, dict) else None + items.append({ + "id": str(w.id), "name": w.name, "description": w.description, + "language": w.language, "status": w.status, + "last_played_at": w.last_played_at.isoformat() if w.last_played_at else None, + "current_time": w.current_time, + "created_at": w.created_at.isoformat(), + "preview_player_name": pname, + }) + return {"items": items, "total": total, "page": page, "per_page": per_page} + + +@router.post("", response_model=dict, status_code=status.HTTP_202_ACCEPTED) +async def create_world( + body: WorldCreateRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), + settings: dict = Depends(get_settings_dict), +) -> dict: + """Create a draft world and start the world_builder flow (SSE).""" + preset: WorldPreset | None = None + if body.mode == "preset": + if body.preset_id is None: + raise HTTPException(400, "preset_id required when mode=preset") + preset = ( + await db.execute(select(WorldPreset).where(WorldPreset.id == body.preset_id)) + ).scalar_one_or_none() + if preset is None: + raise HTTPException(404, "preset not found") + if not preset.is_public and preset.owner_id != user.id and not user.is_admin: + raise HTTPException(403, "preset not accessible") + world = World( + owner_id=user.id, + preset_id=preset.id if preset else None, + name=body.name, + description=body.notes, + language=body.language, + status="draft", + current_time="day_1_hour_8", + ) + if preset: + world.rules = preset.rules + world.time_schema = preset.time_schema + world.schemas = preset.schemas + world.environment_schema = preset.environment_schema + world.environment = dict(preset.environment_initial) + db.add(world) + await db.commit() + await db.refresh(world) + return { + "world_id": str(world.id), + "stream_url": f"/api/sessions/worlds/{world.id}/builder/stream", + } + + +@router.get("/{world_id}", response_model=WorldFull) +async def get_world( + world_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +) -> World: + world = _load_world(db, world_id, user) + return await world + + +@router.patch("/{world_id}", response_model=WorldFull) +async def patch_world( + world_id: uuid.UUID, + body: WorldPatchRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +) -> World: + world = await _load_world(db, world_id, user) + # Optimistic locking + if body.updated_at is not None and body.updated_at != world.updated_at: + raise HTTPException(409, "state_conflict") + for k, v in body.model_dump(exclude_unset=True, exclude_none=True).items(): + if k == "updated_at": + continue + setattr(world, k, v) + await db.commit() + await db.refresh(world) + return world + + +@router.delete("/{world_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +async def delete_world( + world_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +) -> Response: + world = await _load_world(db, world_id, user) + world.status = "archived" + await db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post("/{world_id}/edit", response_model=dict, status_code=status.HTTP_202_ACCEPTED) +async def edit_world( + world_id: uuid.UUID, + body: WorldEditRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), + settings: dict = Depends(get_settings_dict), +) -> dict: + """Start the world_editor flow with a text instruction.""" + world = await _load_world(db, world_id, user) + return {"stream_url": f"/api/sessions/worlds/{world.id}/editor/stream?instruction={body.instruction}"} + + +async def _load_world(db: AsyncSession, world_id: uuid.UUID, user: User) -> World: + world = ( + await db.execute(select(World).where(World.id == world_id)) + ).scalar_one_or_none() + if world is None: + raise HTTPException(404, "not_found") + if world.owner_id != user.id and not user.is_admin: + raise HTTPException(403, "not_owner") + return world diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..347bbac --- /dev/null +++ b/app/config.py @@ -0,0 +1,125 @@ +"""Application configuration loaded from environment variables / settings DB. + +Settings are layered: +1. Defaults defined in this module. +2. Overrides from environment variables (or `.env` file). +3. Runtime overrides from the `settings` table (loaded on startup and cached). + +The Settings class below is a Pydantic-Settings model — it only handles (1) and (2). +The runtime DB overrides are managed by `app.core.settings_service`. +""" + +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Layered configuration for the AI-RPG backend.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # === Application === + app_name: str = "AI-RPG" + app_version: str = "1.0.0" + debug: bool = False + log_level: str = "INFO" + secret_key: str = "change-me-in-production-please-32-bytes-long" + jwt_algorithm: str = "HS256" + access_token_expire_minutes: int = 60 * 24 # 24 hours + refresh_token_expire_minutes: int = 60 * 24 * 7 # 7 days + cors_origins: list[str] = Field(default_factory=lambda: ["*"]) + + # === Admin setup === + admin_setup_token: str = "" # if empty, will be auto-generated and stored in DB + + # === Database === + database_url: str = "postgresql+asyncpg://airpg:airpg@localhost:5432/airpg" + database_url_sync: str = "postgresql+psycopg2://airpg:airpg@localhost:5432/airpg" + db_pool_size: int = 10 + db_max_overflow: int = 20 + db_echo: bool = False + + # === Qdrant === + qdrant_url: str = "http://localhost:6333" + qdrant_api_key: str = "" + qdrant_collection_prefix: str = "" + qdrant_timeout: float = 30.0 + + # === LLM (defaults; runtime overrides in `settings` table) === + llm_api_url: str = "http://localhost:11434/v1" + llm_api_key: str = "" + llm_model: str = "qwen2.5-7b-instruct" + llm_temperature_orchestrator: float = 0.7 + llm_temperature_writer: float = 0.85 + llm_max_tokens: int = 2048 + llm_timeout_seconds: int = 60 + + # === Embeddings === + embeddings_provider: str = "offline_hash" # "offline_hash" | "openai" + embeddings_api_url: str = "" + embeddings_api_key: str = "" + embeddings_model: str = "text-embedding-3-small" + embeddings_dimension: int = 256 # for offline_hash; will be probed for openai + embeddings_timeout_seconds: int = 30 + embeddings_batch_size: int = 32 + embeddings_cache_ttl_seconds: int = 300 + embeddings_max_text_chars: int = 4000 + + # === Context manager === + context_guaranteed_messages: int = 10 + context_compression_threshold_messages: int = 20 + context_compression_threshold_tokens: int = 6000 + context_scene_text_truncate_tokens: int = 500 + context_auto_rag_on_entity_mention: bool = False + context_safety_margin_tokens: int = 500 + llm_context_window_tokens: int = 8192 # for tokenizer-based budgeting + + # === Game === + game_deferred_triggers_enabled: bool = True + game_max_substeps_per_iteration: int = 8 + game_max_suggested_actions: int = 3 + + # === UI === + ui_page_title: str = "AI-RPG" + ui_favicon_url: str = "/icon.png" + ui_logo_url: str = "/icon.png" + ui_og_image_url: str = "" + + # === Storage === + data_dir: str = "/home/z/my-project/ai-rpg/data" + assets_dir: str = "" # computed in __init__ + max_upload_size_bytes: int = 1024 * 1024 # 1 MB + + def __init__(self, **values): + super().__init__(**values) + if not self.assets_dir: + self.assets_dir = str(Path(self.data_dir) / "assets") + + @field_validator("cors_origins", mode="before") + @classmethod + def _split_cors(cls, v): + if isinstance(v, str): + return [item.strip() for item in v.split(",") if item.strip()] + return v + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """Cached settings instance. Use as the single source of truth for env config.""" + return Settings() + + +def reload_settings() -> Settings: + """Force reload settings (used in tests).""" + get_settings.cache_clear() + return get_settings() diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..013521d --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +"""Empty package marker.""" diff --git a/app/core/embeddings.py b/app/core/embeddings.py new file mode 100644 index 0000000..87892b4 --- /dev/null +++ b/app/core/embeddings.py @@ -0,0 +1,153 @@ +"""Embedders for RAG. + +Two implementations: +- `HashEmbedder`: offline, deterministic bag-of-words + hash projection. Used for dev/test. +- `OpenAIEmbedder`: calls an OpenAI-compatible embeddings API at runtime. + +The active embedder is chosen via `settings.embeddings.provider`. +""" + +from __future__ import annotations + +import hashlib +import math +import re +from collections import Counter +from typing import Protocol, runtime_checkable + +import httpx + +from app.core.logging import get_logger + +_logger = get_logger(__name__) + +_WORD_RE = re.compile(r"\w+", re.UNICODE) + + +def _tokenize(text: str) -> list[str]: + return [w.lower() for w in _WORD_RE.findall(text)] + + +@runtime_checkable +class Embedder(Protocol): + async def embed(self, texts: list[str]) -> list[list[float]]: ... + + @property + def dimension(self) -> int: ... + + +class HashEmbedder: + """Offline bag-of-words embedder with hash projection. + + Not semantically meaningful, but deterministic and fast — sufficient for + integration tests and local dev. Cosine similarity is non-zero only when + texts share tokens. + """ + + def __init__(self, dimension: int = 256): + if dimension <= 0: + raise ValueError("dimension must be positive") + self._dim = dimension + + @property + def dimension(self) -> int: + return self._dim + + async def embed(self, texts: list[str]) -> list[list[float]]: + out: list[list[float]] = [] + for text in texts: + out.append(self._hash_project(text)) + return out + + def _hash_project(self, text: str) -> list[float]: + vec = [0.0] * self._dim + tokens = _tokenize(text) + if not tokens: + return vec + counts = Counter(tokens) + for token, count in counts.items(): + h = hashlib.md5(token.encode("utf-8")).digest() + # Use first 4 bytes for index, next 4 bytes for sign + idx = int.from_bytes(h[:4], "little") % self._dim + sign = 1.0 if (h[4] & 1) == 0 else -1.0 + vec[idx] += sign * math.sqrt(count) + # L2 normalize + norm = math.sqrt(sum(v * v for v in vec)) + if norm > 0: + vec = [v / norm for v in vec] + return vec + + +class OpenAIEmbedder: + """OpenAI-compatible embeddings API client.""" + + def __init__( + self, + api_url: str, + api_key: str, + model: str, + dimension: int, + timeout: float = 30.0, + batch_size: int = 32, + ): + self._api_url = api_url.rstrip("/") + self._api_key = api_key + self._model = model + self._dim = dimension + self._timeout = timeout + self._batch_size = batch_size + + @property + def dimension(self) -> int: + return self._dim + + async def embed(self, texts: list[str]) -> list[list[float]]: + if not texts: + return [] + out: list[list[float]] = [] + async with httpx.AsyncClient(timeout=self._timeout) as client: + for i in range(0, len(texts), self._batch_size): + batch = texts[i : i + self._batch_size] + resp = await client.post( + f"{self._api_url}/embeddings", + headers={"Authorization": f"Bearer {self._api_key}"}, + json={"model": self._model, "input": batch}, + ) + resp.raise_for_status() + data = resp.json() + # Sort by index to preserve order + sorted_data = sorted(data["data"], key=lambda x: x["index"]) + out.extend(d["embedding"] for d in sorted_data) + return out + + async def probe_dimension(self, sample_text: str = "hello world") -> int: + """Make a single embedding call and return the dimension of the result. + + Useful for the "auto-probe dimension" admin button. + """ + result = await self.embed([sample_text]) + if not result: + raise RuntimeError("Empty embeddings response") + return len(result[0]) + + +def build_hash_embedder(dimension: int) -> HashEmbedder: + return HashEmbedder(dimension=dimension) + + +def build_openai_embedder( + api_url: str, + api_key: str, + model: str, + dimension: int, + timeout: float = 30.0, + batch_size: int = 32, +) -> OpenAIEmbedder: + return OpenAIEmbedder( + api_url=api_url, + api_key=api_key, + model=model, + dimension=dimension, + timeout=timeout, + batch_size=batch_size, + ) diff --git a/app/core/llm.py b/app/core/llm.py new file mode 100644 index 0000000..9785dda --- /dev/null +++ b/app/core/llm.py @@ -0,0 +1,462 @@ +"""LLM client — OpenAI-compatible API wrapper with retry, logging, and streaming. + +Usage: + client = LlmClient.from_settings(settings_dict) + resp = await client.complete( + stage="orchestrator_phase1", + messages=[{"role": "system", "content": "..."}, ...], + tools=[...], # optional + temperature=0.7, + max_tokens=2048, + stream=False, # if True, returns an async iterator of deltas + user_id=..., world_id=..., step_id=..., + ) +""" + +from __future__ import annotations + +import asyncio +import json +import time +import uuid +from collections.abc import AsyncIterator +from typing import Any + +import httpx +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logging import get_logger +from app.models import LlmCallLog + +_logger = get_logger(__name__) + + +class LLMError(Exception): + """Base LLM error.""" + + def __init__(self, code: str, message: str, status: str = "api_error"): + super().__init__(message) + self.code = code + self.status = status + + +class LLMTimeoutError(LLMError): + def __init__(self, message: str = "LLM call timed out"): + super().__init__("llm_timeout", message, status="timeout") + + +class LLMUnavailableError(LLMError): + def __init__(self, message: str = "LLM provider unavailable"): + super().__init__("llm_unavailable", message, status="api_error") + + +class LLMResponseError(LLMError): + def __init__(self, message: str, code: str = "parse_error"): + super().__init__(code, message, status="parse_error") + + +class LlmClient: + """OpenAI-compatible LLM client with retry, logging, and streaming.""" + + def __init__( + self, + api_url: str, + api_key: str, + model: str, + timeout: float = 60.0, + max_retries: int = 3, + ): + self._api_url = api_url.rstrip("/") + self._api_key = api_key + self._model = model + self._timeout = timeout + self._max_retries = max_retries + + # ------------------------------------------------------------------ # + # Construction + # ------------------------------------------------------------------ # + @classmethod + def from_settings(cls, settings: dict[str, Any]) -> "LlmClient": + return cls( + api_url=settings.get("llm.api_url", "http://localhost:11434/v1"), + api_key=settings.get("llm.api_key", ""), + model=settings.get("llm.model", "qwen2.5-7b-instruct"), + timeout=float(settings.get("llm.timeout_seconds", 60)), + ) + + # ------------------------------------------------------------------ # + # Non-streaming call + # ------------------------------------------------------------------ # + async def complete( + self, + *, + stage: str, + messages: list[dict[str, Any]], + tools: list[dict] | None = None, + tool_choice: Any = None, + temperature: float = 0.7, + top_p: float = 0.9, + max_tokens: int = 2048, + user_id: uuid.UUID | None = None, + world_id: uuid.UUID | None = None, + step_id: uuid.UUID | None = None, + session: AsyncSession | None = None, + stream: bool = False, + ) -> dict[str, Any]: + """Make a non-streaming chat completion call. + + Returns a dict with keys: + - `message`: assistant message (with `content` and optional `tool_calls`) + - `finish_reason`: stop | length | tool_calls + - `prompt_tokens`, `completion_tokens`, `latency_ms` + - `log_id`: id of the LlmCallLog row if `session` provided + """ + if stream: + raise ValueError("Use stream_complete() for streaming calls") + + payload: dict[str, Any] = { + "model": self._model, + "messages": messages, + "temperature": temperature, + "top_p": top_p, + "max_tokens": max_tokens, + "stream": False, + } + if tools: + payload["tools"] = tools + payload["tool_choice"] = tool_choice or "auto" + + start = time.monotonic() + last_exc: Exception | None = None + for attempt in range(self._max_retries): + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.post( + f"{self._api_url}/chat/completions", + headers={ + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) + if resp.status_code >= 500: + raise LLMUnavailableError( + f"LLM provider returned {resp.status_code}: {resp.text[:200]}" + ) + if resp.status_code == 429: + raise LLMUnavailableError("LLM provider rate-limited (429)") + if resp.status_code >= 400: + raise LLMResponseError( + f"LLM provider returned {resp.status_code}: {resp.text[:500]}", + code="api_error", + ) + data = resp.json() + break + except (httpx.TimeoutException, asyncio.TimeoutError) as e: + last_exc = LLMTimeoutError(str(e)) + _logger.warning( + "llm_timeout", stage=stage, attempt=attempt + 1, error=str(e) + ) + except (httpx.ConnectError, httpx.NetworkError) as e: + last_exc = LLMUnavailableError(str(e)) + _logger.warning( + "llm_connection_error", stage=stage, attempt=attempt + 1, error=str(e) + ) + except LLMError as e: + last_exc = e + _logger.warning( + "llm_error", stage=stage, attempt=attempt + 1, error=str(e) + ) + # exponential backoff + await asyncio.sleep(min(2**attempt, 4)) + else: + # All retries exhausted + if session is not None: + await self._write_log_safely( + session=session, + stage=stage, + messages=messages, + tools=tools, + response_message={}, + tool_calls=None, + prompt_tokens=None, + completion_tokens=None, + latency_ms=int((time.monotonic() - start) * 1000), + temperature=temperature, + status=last_exc.status if isinstance(last_exc, LLMError) else "api_error", + error_message=str(last_exc) if last_exc else "unknown", + user_id=user_id, + world_id=world_id, + step_id=step_id, + ) + assert last_exc is not None + raise last_exc + + latency_ms = int((time.monotonic() - start) * 1000) + choice = data["choices"][0] + msg = choice.get("message", {}) + finish_reason = choice.get("finish_reason", "stop") + usage = data.get("usage", {}) + + log_id: uuid.UUID | None = None + if session is not None: + log_id = await self._write_log_safely( + session=session, + stage=stage, + messages=messages, + tools=tools, + response_message=msg, + tool_calls=msg.get("tool_calls"), + prompt_tokens=usage.get("prompt_tokens"), + completion_tokens=usage.get("completion_tokens"), + latency_ms=latency_ms, + temperature=temperature, + status="ok", + error_message=None, + user_id=user_id, + world_id=world_id, + step_id=step_id, + ) + + return { + "message": msg, + "finish_reason": finish_reason, + "prompt_tokens": usage.get("prompt_tokens"), + "completion_tokens": usage.get("completion_tokens"), + "latency_ms": latency_ms, + "log_id": log_id, + } + + # ------------------------------------------------------------------ # + # Streaming call + # ------------------------------------------------------------------ # + async def stream_complete( + self, + *, + stage: str, + messages: list[dict[str, Any]], + tools: list[dict] | None = None, + tool_choice: Any = None, + temperature: float = 0.85, + top_p: float = 0.95, + max_tokens: int = 2048, + user_id: uuid.UUID | None = None, + world_id: uuid.UUID | None = None, + step_id: uuid.UUID | None = None, + session: AsyncSession | None = None, + ) -> AsyncIterator[dict[str, Any]]: + """Stream chat completion. Yields dicts with keys: + - `delta`: {content?, tool_calls?} + - `finish_reason`: present only on the final chunk + After the iterator is exhausted, the call is logged to `llm_call_logs`. + """ + payload: dict[str, Any] = { + "model": self._model, + "messages": messages, + "temperature": temperature, + "top_p": top_p, + "max_tokens": max_tokens, + "stream": True, + } + if tools: + payload["tools"] = tools + payload["tool_choice"] = tool_choice or "auto" + + start = time.monotonic() + full_content_parts: list[str] = [] + full_tool_calls: list[dict] = [] + finish_reason: str | None = None + usage: dict[str, Any] = {} + status = "ok" + error_message: str | None = None + + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: + async with client.stream( + "POST", + f"{self._api_url}/chat/completions", + headers={ + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) as resp: + if resp.status_code >= 400: + body = await resp.aread() + raise LLMResponseError( + f"LLM provider returned {resp.status_code}: {body.decode('utf-8', 'ignore')[:500]}", + code="api_error", + ) + async for line in resp.aiter_lines(): + if not line: + continue + if line.startswith("data: "): + line = line[6:] + if line.strip() == "[DONE]": + break + try: + chunk = json.loads(line) + except json.JSONDecodeError: + continue + if not chunk.get("choices"): + if chunk.get("usage"): + usage = chunk["usage"] + continue + choice = chunk["choices"][0] + delta = choice.get("delta", {}) + if delta.get("content"): + full_content_parts.append(delta["content"]) + if delta.get("tool_calls"): + full_tool_calls.extend(delta["tool_calls"]) + if choice.get("finish_reason"): + finish_reason = choice["finish_reason"] + yield {"delta": delta, "finish_reason": finish_reason} + except Exception as e: + status = "api_error" if not isinstance(e, LLMTimeoutError) else "timeout" + error_message = str(e) + _logger.warning("llm_stream_error", stage=stage, error=error_message) + raise + finally: + latency_ms = int((time.monotonic() - start) * 1000) + if session is not None: + full_content = "".join(full_content_parts) + await self._write_log_safely( + session=session, + stage=stage, + messages=messages, + tools=tools, + response_message={ + "role": "assistant", + "content": full_content, + "tool_calls": full_tool_calls or None, + }, + tool_calls=full_tool_calls or None, + prompt_tokens=usage.get("prompt_tokens"), + completion_tokens=usage.get("completion_tokens"), + latency_ms=latency_ms, + temperature=temperature, + status=status, + error_message=error_message, + user_id=user_id, + world_id=world_id, + step_id=step_id, + ) + + # ------------------------------------------------------------------ # + # Safe logging (separate transaction) + # ------------------------------------------------------------------ # + async def _write_log_safely( + self, + *, + session: AsyncSession, + stage: str, + messages: list[dict[str, Any]], + tools: list[dict] | None, + response_message: dict[str, Any], + tool_calls: list | None, + prompt_tokens: int | None, + completion_tokens: int | None, + latency_ms: int, + temperature: float, + status: str, + error_message: str | None, + user_id: uuid.UUID | None, + world_id: uuid.UUID | None, + step_id: uuid.UUID | None, + ) -> uuid.UUID | None: + """Insert an LlmCallLog row in a nested transaction so it survives rollback. + + Errors here are logged but never raised — logging is best-effort. + """ + try: + async with session.begin_nested(): + log = LlmCallLog( + user_id=user_id, + world_id=world_id, + step_id=step_id, + stage=stage, + model=self._model, + request_messages=messages, + request_tools=tools, + response_message=response_message, + tool_calls=tool_calls, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + latency_ms=latency_ms, + temperature=temperature, + status=status, + error_message=error_message, + ) + session.add(log) + await session.flush() + log_id = log.id + await session.commit() + return log_id + except Exception as e: # noqa: BLE001 + _logger.error("llm_log_write_failed", stage=stage, error=str(e)) + try: + await session.rollback() + except Exception: + pass + return None + + +# --------------------------------------------------------------------------- # +# Mock LLM client (for tests) +# --------------------------------------------------------------------------- # +class MockLlmClient: + """Replay-based mock LLM client. Returns pre-recorded responses per stage.""" + + def __init__(self, replay_data: dict[str, list[dict]] | None = None): + self._replay = replay_data or {} + self._call_counts: dict[str, int] = {} + # Allow recording mode + self.recorded_calls: list[dict[str, Any]] = [] + + def set_replay(self, stage: str, responses: list[dict]) -> None: + self._replay[stage] = responses + self._call_counts.pop(stage, None) + + async def complete(self, *, stage: str, messages=None, tools=None, **kwargs) -> dict[str, Any]: + idx = self._call_counts.get(stage, 0) + responses = self._replay.get(stage, []) + if idx >= len(responses): + raise LLMResponseError( + f"Replay exhausted for stage {stage} (call #{idx + 1})", + code="replay_exhausted", + ) + resp = responses[idx] + self._call_counts[stage] = idx + 1 + self.recorded_calls.append({"stage": stage, "messages": messages, "tools": tools}) + + # Mimic the real client's return shape + return { + "message": resp.get("message", {"role": "assistant", "content": resp.get("content", "")}), + "finish_reason": resp.get("finish_reason", "stop"), + "prompt_tokens": resp.get("prompt_tokens", 0), + "completion_tokens": resp.get("completion_tokens", 0), + "latency_ms": 0, + "log_id": None, + } + + async def stream_complete(self, *, stage: str, messages=None, tools=None, **kwargs): + idx = self._call_counts.get(stage, 0) + responses = self._replay.get(stage, []) + if idx >= len(responses): + raise LLMResponseError( + f"Replay exhausted for stage {stage} (call #{idx + 1})", + code="replay_exhausted", + ) + resp = responses[idx] + self._call_counts[stage] = idx + 1 + content = resp.get("message", {}).get("content", resp.get("content", "")) + # Yield content in 3 chunks for streaming tests + chunk_size = max(1, len(content) // 3) + for i in range(0, len(content), chunk_size): + yield {"delta": {"content": content[i : i + chunk_size]}, "finish_reason": None} + yield {"delta": {}, "finish_reason": "stop"} + + +def get_mock_client() -> MockLlmClient: + """Convenience factory — used in tests and as a fallback in dev when no LLM configured.""" + return MockLlmClient() diff --git a/app/core/logging.py b/app/core/logging.py new file mode 100644 index 0000000..b28bd5f --- /dev/null +++ b/app/core/logging.py @@ -0,0 +1,48 @@ +"""Application logging setup using structlog.""" + +from __future__ import annotations + +import logging +import sys + +import structlog + +from app.config import get_settings + + +def configure_logging() -> None: + """Configure structlog + stdlib logging once at startup.""" + cfg = get_settings() + level = getattr(logging, cfg.log_level.upper(), logging.INFO) + + # stdlib root logger + logging.basicConfig( + level=level, + format="%(message)s", + stream=sys.stdout, + ) + + # structlog processors — JSON output in prod, pretty console in dev + shared_processors = [ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + ] + if cfg.debug: + renderer = structlog.dev.ConsoleRenderer(colors=True) + else: + renderer = structlog.processors.JSONRenderer() + + structlog.configure( + processors=shared_processors + [renderer], + wrapper_class=structlog.make_filtering_bound_logger(level), + logger_factory=structlog.PrintLoggerFactory(), + cache_logger_on_first_use=True, + ) + + +def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger: + """Return a structlog logger bound to `name`.""" + return structlog.get_logger(name) # type: ignore[return-value] diff --git a/app/core/qdrant_client.py b/app/core/qdrant_client.py new file mode 100644 index 0000000..070e0f5 --- /dev/null +++ b/app/core/qdrant_client.py @@ -0,0 +1,130 @@ +"""Qdrant client wrapper (singleton) with health check.""" + +from __future__ import annotations + +from typing import Any + +from qdrant_client import AsyncQdrantClient +from qdrant_client.http.models import ( + Distance, + PayloadSchemaType, + VectorParams, +) + +from app.config import get_settings +from app.core.logging import get_logger + +_logger = get_logger(__name__) + +_client: AsyncQdrantClient | None = None + + +def get_qdrant_client() -> AsyncQdrantClient: + """Return the singleton AsyncQdrantClient.""" + global _client + if _client is None: + cfg = get_settings() + _client = AsyncQdrantClient( + url=cfg.qdrant_url, + api_key=cfg.qdrant_api_key or None, + timeout=cfg.qdrant_timeout, + ) + return _client + + +async def dispose_qdrant_client() -> None: + """Close the Qdrant client (on shutdown).""" + global _client + if _client is not None: + try: + await _client.close() + except Exception as e: # noqa: BLE001 + _logger.warning("qdrant_close_failed", error=str(e)) + _client = None + + +async def ping_qdrant() -> bool: + """Health-check: returns True if Qdrant responds.""" + try: + client = get_qdrant_client() + await client.get_collections() + return True + except Exception as e: # noqa: BLE001 + _logger.warning("qdrant_ping_failed", error=str(e)) + return False + + +async def init_qdrant_collections(dimension: int) -> dict[str, Any]: + """Create collections `entities` and `story_entries` if missing. + + Returns a dict with the list of created collection names and the dimension used. + """ + cfg = get_settings() + prefix = cfg.qdrant_collection_prefix or "" + client = get_qdrant_client() + + existing = {c.name for c in (await client.get_collections()).collections} + created: list[str] = [] + + collections_config = { + f"{prefix}entities": [ + ("world_id", PayloadSchemaType.KEYWORD), + ("entity_type", PayloadSchemaType.KEYWORD), + ("deleted", PayloadSchemaType.BOOL), + ], + f"{prefix}story_entries": [ + ("world_id", PayloadSchemaType.KEYWORD), + ("entry_type", PayloadSchemaType.KEYWORD), + ("created_at", PayloadSchemaType.INTEGER), + ], + } + + for name, indexes in collections_config.items(): + if name in existing: + continue + await client.create_collection( + collection_name=name, + vectors_config=VectorParams(size=dimension, distance=Distance.COSINE), + ) + for field, schema_type in indexes: + await client.create_payload_index(name, field, schema_type) + created.append(name) + _logger.info("qdrant_collection_created", name=name, dimension=dimension) + + return {"created": created, "dimension": dimension, "existing": sorted(existing)} + + +async def cleanup_world_points(world_id: str) -> None: + """Best-effort delete of all Qdrant points for a given world_id.""" + from qdrant_client.http.models import ( + FieldCondition, + Filter, + FilterSelector, + MatchValue, + ) + + cfg = get_settings() + prefix = cfg.qdrant_collection_prefix or "" + client = get_qdrant_client() + for collection in (f"{prefix}entities", f"{prefix}story_entries"): + try: + await client.delete( + collection_name=collection, + points_selector=FilterSelector( + filter=Filter( + must=[ + FieldCondition( + key="world_id", + match=MatchValue(value=str(world_id)), + ) + ] + ) + ), + ) + except Exception as e: # noqa: BLE001 + _logger.error( + "qdrant_cleanup_failed", + collection=collection, + world_id=str(world_id), + error=str(e), + ) diff --git a/app/core/rag.py b/app/core/rag.py new file mode 100644 index 0000000..4e2c352 --- /dev/null +++ b/app/core/rag.py @@ -0,0 +1,326 @@ +"""RAG — retrieval-augmented generation through Qdrant + PostgreSQL. + +Two-stage retrieval: +1. Vector search in Qdrant (filtered by world_id). +2. Hydrate full entity/story-entry data from PostgreSQL by IDs. +""" + +from __future__ import annotations + +import time +import uuid +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.core.embeddings import ( + HashEmbedder, + OpenAIEmbedder, + build_hash_embedder, + build_openai_embedder, +) +from app.core.logging import get_logger +from app.core.qdrant_client import get_qdrant_client +from app.models import Entity, StoryEntry + +_logger = get_logger(__name__) + +_embedder_cache: dict[str, Any] = {} + + +async def get_embedder(): + """Return the active Embedder based on settings. + + Falls back to HashEmbedder if the OpenAI embedder cannot be built. + """ + from app.core.settings_service import get_all_settings + + # We can't take a DB session here — use a module-level cache. + # On settings change the admin should hit "test embeddings" which clears the cache. + if "active" in _embedder_cache: + return _embedder_cache["active"] + + cfg = get_settings() + provider = cfg.embeddings_provider + if provider == "offline_hash": + emb = build_hash_embedder(cfg.embeddings_dimension) + elif provider == "openai": + api_url = cfg.embeddings_api_url or cfg.llm_api_url + api_key = cfg.embeddings_api_key or cfg.llm_api_key + if not api_url: + _logger.warning("embeddings_openai_no_url_fallback_hash") + emb = build_hash_embedder(cfg.embeddings_dimension) + else: + emb = build_openai_embedder( + api_url=api_url, + api_key=api_key, + model=cfg.embeddings_model, + dimension=cfg.embeddings_dimension, + timeout=float(cfg.embeddings_timeout_seconds), + batch_size=cfg.embeddings_batch_size, + ) + else: + _logger.warning("embeddings_unknown_provider_fallback_hash", provider=provider) + emb = build_hash_embedder(cfg.embeddings_dimension) + + _embedder_cache["active"] = emb + return emb + + +def reset_embedder_cache() -> None: + """Clear the cached embedder (used by admin test endpoints after settings change).""" + _embedder_cache.clear() + + +async def rag_query( + *, + db: AsyncSession, + world_id: uuid.UUID, + query: str, + limit: int = 5, + filter_type: str = "all", + min_score: float = 0.0, +) -> list[dict[str, Any]]: + """Semantic search over entities + story_entries via Qdrant.""" + cfg = get_settings() + prefix = cfg.qdrant_collection_prefix or "" + + embedder = await get_embedder() + try: + vecs = await embedder.embed([query[: cfg.embeddings_max_text_chars]]) + if not vecs: + return [] + query_vec = vecs[0] + except Exception as e: # noqa: BLE001 + _logger.warning("rag_query_embed_failed", error=str(e)) + return [] + + client = get_qdrant_client() + from qdrant_client.http.models import ( + FieldCondition, + Filter, + MatchValue, + ) + + world_filter = FieldCondition( + key="world_id", match=MatchValue(value=str(world_id)) + ) + + raw_results: list[dict[str, Any]] = [] + + if filter_type in ("all", "entities"): + try: + ents = await client.search( + collection_name=f"{prefix}entities", + query_vector=query_vec, + query_filter=Filter( + must=[ + world_filter, + FieldCondition( + key="deleted", match=MatchValue(value=False) + ), + ] + ), + limit=limit, + score_threshold=min_score, + with_payload=True, + ) + for p in ents: + raw_results.append({ + "type": "entity", + "id": p.payload.get("entity_id"), + "score": float(p.score), + "name": p.payload.get("name"), + "entity_type": p.payload.get("entity_type"), + }) + except Exception as e: # noqa: BLE001 + _logger.warning("rag_query_entities_failed", error=str(e)) + + if filter_type in ("all", "story_entries"): + try: + sts = await client.search( + collection_name=f"{prefix}story_entries", + query_vector=query_vec, + query_filter=Filter(must=[world_filter]), + limit=limit, + score_threshold=min_score, + with_payload=True, + ) + for p in sts: + raw_results.append({ + "type": "story_entry", + "id": p.payload.get("entry_id"), + "score": float(p.score), + "entry_type": p.payload.get("entry_type"), + }) + except Exception as e: # noqa: BLE001 + _logger.warning("rag_query_stories_failed", error=str(e)) + + # Sort and truncate + raw_results.sort(key=lambda r: r["score"], reverse=True) + top = raw_results[:limit] + + return await _hydrate(db, top, world_id) + + +async def _hydrate( + db: AsyncSession, items: list[dict[str, Any]], world_id: uuid.UUID +) -> list[dict[str, Any]]: + """Stage 2: pull full records from PostgreSQL by IDs.""" + entity_ids = [uuid.UUID(i["id"]) for i in items if i["type"] == "entity"] + story_ids = [uuid.UUID(i["id"]) for i in items if i["type"] == "story_entry"] + + ents_map: dict[uuid.UUID, Entity] = {} + stories_map: dict[uuid.UUID, StoryEntry] = {} + if entity_ids: + rows = ( + await db.execute( + select(Entity).where( + Entity.id.in_(entity_ids), Entity.world_id == world_id + ) + ) + ).scalars().all() + ents_map = {r.id: r for r in rows} + if story_ids: + rows = ( + await db.execute( + select(StoryEntry).where( + StoryEntry.id.in_(story_ids), StoryEntry.world_id == world_id + ) + ) + ).scalars().all() + stories_map = {r.id: r for r in rows} + + out: list[dict[str, Any]] = [] + for i in items: + if i["type"] == "entity": + ent = ents_map.get(uuid.UUID(i["id"])) + if ent and ent.deleted_at is None: + out.append({ + **i, + "content": { + "entity_type": ent.entity_type, + "name": ent.name, + "data": ent.data, + }, + }) + else: + se = stories_map.get(uuid.UUID(i["id"])) + if se: + out.append({ + **i, + "content": { + "text": se.content, + "entry_type": se.entry_type, + "metadata": se.metadata_, + }, + }) + return out + + +async def rag_add( + *, + db: AsyncSession, + world_id: uuid.UUID, + content: str, + entry_type: str, + metadata: dict | None = None, + step_id: uuid.UUID | None = None, +) -> StoryEntry: + """Add a story entry and index it in Qdrant (best-effort).""" + cfg = get_settings() + prefix = cfg.qdrant_collection_prefix or "" + + entry = StoryEntry( + world_id=world_id, + content=content, + entry_type=entry_type, + metadata_=metadata or {}, + embedding_status="pending", + ) + db.add(entry) + await db.flush() + + try: + embedder = await get_embedder() + vecs = await embedder.embed([content[: cfg.embeddings_max_text_chars]]) + if vecs: + point_id = str(entry.id) + from qdrant_client.http.models import PointStruct + + await get_qdrant_client().upsert( + collection_name=f"{prefix}story_entries", + points=[ + PointStruct( + id=point_id, + vector=vecs[0], + payload={ + "world_id": str(world_id), + "entry_id": point_id, + "entry_type": entry_type, + "step_id": str(step_id) if step_id else None, + "created_at": int(time.time()), + }, + ) + ], + ) + entry.qdrant_point_id = point_id + entry.embedding_status = "indexed" + except Exception as e: # noqa: BLE001 + _logger.warning("rag_add_embed_failed", entry_id=str(entry.id), error=str(e)) + entry.embedding_status = "failed" + + await db.flush() + return entry + + +async def index_entity( + *, + db: AsyncSession, + entity: Entity, +) -> None: + """Index (or re-index) an entity's vector in Qdrant.""" + cfg = get_settings() + prefix = cfg.qdrant_collection_prefix or "" + text = entity.name + " " + _stringify(entity.data) + try: + embedder = await get_embedder() + vecs = await embedder.embed([text[: cfg.embeddings_max_text_chars]]) + if not vecs: + return + point_id = str(entity.id) + from qdrant_client.http.models import PointStruct + + await get_qdrant_client().upsert( + collection_name=f"{prefix}entities", + points=[ + PointStruct( + id=point_id, + vector=vecs[0], + payload={ + "world_id": str(entity.world_id), + "entity_id": point_id, + "entity_type": entity.entity_type, + "name": entity.name, + "deleted": entity.deleted_at is not None, + }, + ) + ], + ) + entity.qdrant_point_id = point_id + entity.embedding_status = "indexed" + except Exception as e: # noqa: BLE001 + _logger.warning("entity_index_failed", entity_id=str(entity.id), error=str(e)) + entity.embedding_status = "failed" + await db.flush() + + +def _stringify(obj: Any) -> str: + import json + + try: + return json.dumps(obj, ensure_ascii=False, default=str) + except Exception: # noqa: BLE001 + return str(obj) diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..84e67c0 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,85 @@ +"""Security: JWT creation/verification and password hashing.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.config import get_settings + +_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(plain: str) -> str: + """Hash a password using bcrypt.""" + return _pwd_context.hash(plain) + + +def verify_password(plain: str, hashed: str) -> bool: + """Verify a password against its bcrypt hash.""" + try: + return _pwd_context.verify(plain, hashed) + except (ValueError, TypeError): + return False + + +def create_access_token( + subject: str | uuid.UUID, + extra_claims: dict[str, Any] | None = None, + expires_in_minutes: int | None = None, +) -> str: + """Create a signed JWT access token.""" + cfg = get_settings() + minutes = expires_in_minutes or cfg.access_token_expire_minutes + now = datetime.now(timezone.utc) + payload: dict[str, Any] = { + "sub": str(subject), + "iat": int(now.timestamp()), + "exp": int((now + timedelta(minutes=minutes)).timestamp()), + "type": "access", + } + if extra_claims: + payload.update(extra_claims) + return jwt.encode(payload, cfg.secret_key, algorithm=cfg.jwt_algorithm) + + +def create_refresh_token( + subject: str | uuid.UUID, expires_in_minutes: int | None = None +) -> str: + """Create a signed JWT refresh token.""" + cfg = get_settings() + minutes = expires_in_minutes or cfg.refresh_token_expire_minutes + now = datetime.now(timezone.utc) + payload = { + "sub": str(subject), + "iat": int(now.timestamp()), + "exp": int((now + timedelta(minutes=minutes)).timestamp()), + "type": "refresh", + } + return jwt.encode(payload, cfg.secret_key, algorithm=cfg.jwt_algorithm) + + +def decode_token(token: str) -> dict[str, Any]: + """Decode and verify a JWT. Raises JWTError on failure.""" + cfg = get_settings() + return jwt.decode(token, cfg.secret_key, algorithms=[cfg.jwt_algorithm]) + + +def validate_password_strength(password: str) -> list[str]: + """Return a list of validation errors (empty list = valid password).""" + errors: list[str] = [] + if len(password) < 8: + errors.append("Password must be at least 8 characters long") + if not any(c.isalpha() for c in password): + errors.append("Password must contain at least one letter") + if not any(c.isdigit() for c in password): + errors.append("Password must contain at least one digit") + # Tiny blacklist of trivial passwords + blacklist = {"password", "12345678", "qwerty12", "password1", "abcdefgh"} + if password.lower() in blacklist: + errors.append("Password is too common") + return errors diff --git a/app/core/settings_service.py b/app/core/settings_service.py new file mode 100644 index 0000000..fc94156 --- /dev/null +++ b/app/core/settings_service.py @@ -0,0 +1,187 @@ +"""Settings service — runtime overrides from the `settings` table. + +Layered: +1. App config (env vars / .env) — `app.config.get_settings()` +2. DB overrides — `settings` table +3. `get_setting(key)` merges them with DB taking precedence. +""" + +from __future__ import annotations + +import secrets +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.models import Setting + +# Default settings written to DB on first run. +# These match the seed list in `docs/AI-RPG_TZ_TDD.md` §5.2.2. +DEFAULT_SETTINGS: dict[str, dict[str, Any]] = { + "llm.api_url": {"value": None, "description": "OpenAI-compatible endpoint URL"}, + "llm.api_key": {"value": "", "description": "API key for LLM (stored as string)"}, + "llm.model": {"value": "qwen2.5-7b-instruct", "description": "Chat model name"}, + "llm.temperature_orchestrator": {"value": 0.7, "description": "Phase 1 temperature"}, + "llm.temperature_writer": {"value": 0.85, "description": "Phase 2 temperature"}, + "llm.max_tokens": {"value": 2048, "description": "Max completion tokens"}, + "llm.timeout_seconds": {"value": 60, "description": "LLM call timeout"}, + "embeddings.provider": { + "value": "offline_hash", + "description": "offline_hash | openai", + }, + "embeddings.api_url": {"value": "", "description": "OpenAI-compatible embeddings URL"}, + "embeddings.api_key": {"value": "", "description": "API key for embeddings"}, + "embeddings.model": {"value": "text-embedding-3-small", "description": "Embedding model"}, + "embeddings.dimension": {"value": 256, "description": "Embedding dimension"}, + "embeddings.timeout_seconds": {"value": 30, "description": "Embeddings API timeout"}, + "embeddings.batch_size": {"value": 32, "description": "Batch size for embeddings API"}, + "embeddings.cache_ttl_seconds": {"value": 300, "description": "LRU cache TTL"}, + "embeddings.max_text_chars": {"value": 4000, "description": "Text truncation before embedding"}, + "context.guaranteed_messages": {"value": 10, "description": "Always-in-context messages"}, + "context.compression_threshold_messages": {"value": 20, "description": "Compression threshold"}, + "context.compression_threshold_tokens": {"value": 6000, "description": "Token-based threshold"}, + "context.scene_text_truncate_tokens": {"value": 500, "description": "scene_text truncation"}, + "context.auto_rag_on_entity_mention": {"value": False, "description": "Auto RAG on entity mention"}, + "context.safety_margin_tokens": {"value": 500, "description": "Safety margin from edge"}, + "qdrant.url": {"value": "http://qdrant:6333", "description": "Qdrant URL"}, + "qdrant.api_key": {"value": "", "description": "Qdrant API key"}, + "qdrant.collection_prefix": {"value": "", "description": "Collection prefix"}, + "game.deferred_triggers_enabled": {"value": True, "description": "Enable deferred triggers"}, + "game.max_substeps_per_iteration": {"value": 8, "description": "Max Phase 1 substeps"}, + "game.max_suggested_actions": {"value": 3, "description": "Max suggested actions"}, + "ui.page_title": {"value": "AI-RPG", "description": "Browser tab title"}, + "ui.favicon_url": {"value": "/icon.png", "description": "Favicon URL"}, + "ui.logo_url": {"value": "/icon.png", "description": "Logo URL"}, + "ui.og_image_url": {"value": "", "description": "OpenGraph image URL"}, + "admin.setup_token": {"value": "", "description": "Admin setup token"}, +} + +# Keys whose values should never be returned to the client in plaintext. +SECRET_KEYS = {"llm.api_key", "embeddings.api_key", "qdrant.api_key", "admin.setup_token"} + +# Map: setting key -> (env-var attribute on Settings, default value) +ENV_OVERRIDE_MAP = { + "llm.api_url": ("llm_api_url", None), + "llm.api_key": ("llm_api_key", None), + "llm.model": ("llm_model", None), + "llm.timeout_seconds": ("llm_timeout_seconds", None), + "embeddings.provider": ("embeddings_provider", None), + "embeddings.api_url": ("embeddings_api_url", None), + "embeddings.api_key": ("embeddings_api_key", None), + "embeddings.model": ("embeddings_model", None), + "embeddings.dimension": ("embeddings_dimension", None), + "qdrant.url": ("qdrant_url", None), + "qdrant.api_key": ("qdrant_api_key", None), + "qdrant.collection_prefix": ("qdrant_collection_prefix", None), + "ui.page_title": ("ui_page_title", None), + "ui.favicon_url": ("ui_favicon_url", None), + "ui.logo_url": ("ui_logo_url", None), +} + + +async def seed_default_settings(session: AsyncSession) -> None: + """Upsert all DEFAULT_SETTINGS rows. Called on application startup.""" + existing = ( + await session.execute(select(Setting).where(Setting.key.in_(DEFAULT_SETTINGS.keys()))) + ).scalars().all() + existing_keys = {row.key for row in existing} + + cfg = get_settings() + for key, spec in DEFAULT_SETTINGS.items(): + if key in existing_keys: + continue + value = spec["value"] + # Apply env-var override on first seed (so docker-compose env wins). + env_attr = ENV_OVERRIDE_MAP.get(key) + if env_attr is not None and env_attr[1] is None: + env_val = getattr(cfg, env_attr[0], None) + if env_val not in (None, ""): + value = env_val + # Special: admin.setup_token — generate random if env not set + if key == "admin.setup_token" and not value: + env_token = cfg.admin_setup_token + value = env_token if env_token else secrets.token_urlsafe(16) + session.add( + Setting(key=key, value=value, description=spec["description"]) + ) + await session.commit() + + +async def get_all_settings(session: AsyncSession) -> dict[str, Any]: + """Return all settings as a dict (with env overrides applied for missing keys).""" + rows = (await session.execute(select(Setting))).scalars().all() + cfg = get_settings() + out: dict[str, Any] = {} + for key, spec in DEFAULT_SETTINGS.items(): + row = next((r for r in rows if r.key == key), None) + if row is not None: + out[key] = row.value + else: + # Fall back to env-var if present, otherwise spec default + env_attr = ENV_OVERRIDE_MAP.get(key) + env_val = ( + getattr(cfg, env_attr[0], None) + if env_attr and env_attr[1] is None + else None + ) + out[key] = env_val if env_val not in (None, "") else spec["value"] + return out + + +async def get_setting(session: AsyncSession, key: str) -> Any: + """Get a single setting by key, with env override fallback.""" + row = ( + await session.execute(select(Setting).where(Setting.key == key)) + ).scalar_one_or_none() + if row is not None: + return row.value + # Env-var fallback + env_attr = ENV_OVERRIDE_MAP.get(key) + if env_attr and env_attr[1] is None: + env_val = getattr(get_settings(), env_attr[0], None) + if env_val not in (None, ""): + return env_val + return DEFAULT_SETTINGS.get(key, {}).get("value") + + +async def set_setting(session: AsyncSession, key: str, value: Any) -> Any: + """Upsert a setting value. Returns the new value.""" + if key not in DEFAULT_SETTINGS: + # Allow ad-hoc keys but warn in logs + import logging + + logging.getLogger(__name__).warning("creating_unregistered_setting", extra={"key": key}) + row = ( + await session.execute(select(Setting).where(Setting.key == key)) + ).scalar_one_or_none() + if row is None: + row = Setting( + key=key, + value=value, + description=DEFAULT_SETTINGS.get(key, {}).get("description"), + ) + session.add(row) + else: + row.value = value + await session.commit() + return value + + +def mask_secret(key: str, value: Any) -> Any: + """Mask secret values for safe display in admin UI.""" + if key in SECRET_KEYS and isinstance(value, str) and value: + if len(value) <= 4: + return "****" + return value[:2] + "…" + "*" * (min(len(value) - 4, 8)) + value[-2:] + return value + + +async def get_admin_setup_token(session: AsyncSession) -> str: + """Return the current admin setup token (generating one if absent).""" + token = await get_setting(session, "admin.setup_token") + if not token: + token = secrets.token_urlsafe(16) + await set_setting(session, "admin.setup_token", token) + return token diff --git a/app/core/state_validator.py b/app/core/state_validator.py new file mode 100644 index 0000000..2c42fd0 --- /dev/null +++ b/app/core/state_validator.py @@ -0,0 +1,307 @@ +"""State validator for environment / entity.data / world schema. + +All mutations of `world.environment` and `entity.data` go through this module. +The orchestrator's `env_update` and `entity_update` tools use `apply_patch`. + +Validation rules: +- Required fields must be present (per `environment_schema` and entity `schemas`). +- Field types must match the declared type. +- Numeric ranges enforced when `max`/`min` provided. +- Nested `object` / `array` schemas are validated recursively. +""" + +from __future__ import annotations + +import re +from typing import Any + +# Supported primitive JSON-schema type names +_PRIMITIVES = {"string", "integer", "number", "boolean"} +_PATCH_OPS = {"set", "inc", "dec", "append", "remove"} + + +def validate_state(state: dict[str, Any], schema_fields: list[dict]) -> tuple[bool, list[str]]: + """Validate `state` against a list of field definitions. + + Each field definition has the shape: + { + "name": "player", + "type": "object" | "array" | "string" | ..., + "required": bool, + "properties": [ ... ], # for type=object + "items": { ... }, # for type=array + "min": int, "max": int, # for numeric types + "default": + } + """ + errors: list[str] = [] + for field in schema_fields: + name = field.get("name") + if not name: + errors.append("Schema field missing 'name'") + continue + if name not in state: + if field.get("required"): + errors.append(f"Missing required field: {name}") + continue + _validate_value(state[name], field, path=name, errors=errors) + return (len(errors) == 0, errors) + + +def _validate_value( + value: Any, field_schema: dict, path: str, errors: list[str] +) -> None: + ftype = field_schema.get("type", "string") + if ftype in _PRIMITIVES: + _validate_primitive(value, ftype, field_schema, path, errors) + elif ftype == "object": + if not isinstance(value, dict): + errors.append(f"{path} must be object") + return + props = field_schema.get("properties", []) + # validate child fields + ok, child_errors = validate_state(value, props) + if not ok: + errors.extend(child_errors) + elif ftype == "array": + if not isinstance(value, list): + errors.append(f"{path} must be array") + return + items_schema = field_schema.get("items") + if items_schema: + for i, item in enumerate(value): + _validate_value(item, items_schema, f"{path}[{i}]", errors) + else: + errors.append(f"{path}: unknown type {ftype!r}") + + +def _validate_primitive( + value: Any, ftype: str, field_schema: dict, path: str, errors: list[str] +) -> None: + if ftype == "string": + if not isinstance(value, str): + errors.append(f"{path} must be string") + return + elif ftype == "integer": + if isinstance(value, bool) or not isinstance(value, int): + errors.append(f"{path} must be integer") + return + elif ftype == "number": + if isinstance(value, bool) or not isinstance(value, (int, float)): + errors.append(f"{path} must be number") + return + elif ftype == "boolean": + if not isinstance(value, bool): + errors.append(f"{path} must be boolean") + return + # Range checks + if ftype in ("integer", "number"): + mn = field_schema.get("min") + mx = field_schema.get("max") + if mn is not None and value < mn: + errors.append(f"{path} must be >= {mn}, got {value}") + if mx is not None and value > mx: + errors.append(f"{path} must be <= {mx}, got {value}") + + +# --------------------------------------------------------------------------- +# Patch application +# --------------------------------------------------------------------------- +_PATH_TOKEN_RE = re.compile(r"\.?([^\.\[\]]+)|\[(\d+)\]") + + +def _split_path(path: str) -> list[tuple[str, int | None]]: + """Split a dotted path into tokens. Supports `arr[0].field` syntax.""" + tokens: list[tuple[str, int | None]] = [] + for m in _PATH_TOKEN_RE.finditer(path): + if m.group(1) is not None and m.group(1) != "": + tokens.append((m.group(1), None)) + elif m.group(2) is not None: + tokens.append(("", int(m.group(2)))) + return tokens + + +def _navigate(state: Any, tokens: list[tuple[str, int | None]]) -> tuple[bool, Any, str]: + """Walk into state along tokens. Returns (ok, value, error).""" + cur = state + for i, (key, idx) in enumerate(tokens): + if idx is not None: + if not isinstance(cur, list): + return False, None, f"cannot index into non-list at {'.'.join(t[0] for t in tokens[:i])}" + if idx >= len(cur): + return False, None, f"index {idx} out of range" + cur = cur[idx] + else: + if not isinstance(cur, dict): + return False, None, f"cannot key into non-object at {'.'.join(t[0] for t in tokens[:i])}" + if key not in cur: + return False, None, f"key {key!r} not found" + cur = cur[key] + return True, cur, "" + + +def _set_path(state: Any, tokens: list[tuple[str, int | None]], value: Any) -> tuple[bool, str]: + """Set value at path, creating intermediate dicts as needed.""" + if not tokens: + return False, "empty path" + cur = state + for i, (key, idx) in enumerate(tokens[:-1]): + nxt_key, nxt_idx = tokens[i + 1] + if idx is not None: + # current is list — descend by index + if not isinstance(cur, list): + return False, "cannot index non-list" + while len(cur) <= idx: + cur.append({}) + cur = cur[idx] + else: + if not isinstance(cur, dict): + return False, "cannot key non-object" + if key not in cur: + cur[key] = [] if nxt_idx is not None else {} + cur = cur[key] + # last token + last_key, last_idx = tokens[-1] + if last_idx is not None: + if not isinstance(cur, list): + return False, "cannot index non-list" + while len(cur) <= last_idx: + cur.append(None) + cur[last_idx] = value + else: + if not isinstance(cur, dict): + return False, "cannot key non-object" + cur[last_key] = value + return True, "" + + +def _remove_path(state: Any, tokens: list[tuple[str, int | None]]) -> tuple[bool, str]: + """Remove the value at path.""" + if not tokens: + return False, "empty path" + parent_tokens = tokens[:-1] + ok, parent, err = _navigate(state, parent_tokens) + if not ok: + return False, err + last_key, last_idx = tokens[-1] + if last_idx is not None: + if not isinstance(parent, list): + return False, "cannot index non-list" + if last_idx >= len(parent): + return False, "index out of range" + parent.pop(last_idx) + else: + if not isinstance(parent, dict): + return False, "cannot key non-object" + if last_key not in parent: + return False, f"key {last_key!r} not found" + del parent[last_key] + return True, "" + + +def apply_patch(state: dict[str, Any], patch: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: + """Apply a patch to `state`. Returns (new_state, errors). + + Patch format: `{field_path: new_value | {op: ..., by: N | value: V}}`. + Supported ops: `set` (default), `inc`, `dec`, `append`, `remove`. + + The state is mutated in place — pass a deepcopy if you need to preserve the original. + """ + import copy + + state = copy.deepcopy(state) + errors: list[str] = [] + for path, op_spec in patch.items(): + tokens = _split_path(path) + if not tokens: + errors.append(f"invalid path: {path!r}") + continue + + # Determine if this is an op-dict or a direct value + if isinstance(op_spec, dict) and "op" in op_spec and op_spec["op"] in _PATCH_OPS: + op = op_spec["op"] + if op == "set": + ok, err = _set_path(state, tokens, op_spec.get("value")) + if not ok: + errors.append(f"{path}: {err}") + elif op in ("inc", "dec"): + by = op_spec.get("by", 1) + if op == "dec": + by = -by + ok, cur, err = _navigate(state, tokens) + if not ok: + # create with the delta value + ok2, err2 = _set_path(state, tokens, by) + if not ok2: + errors.append(f"{path}: {err2}") + else: + if isinstance(cur, bool) or not isinstance(cur, (int, float)): + errors.append(f"{path}: cannot {op} non-number") + else: + ok2, err2 = _set_path(state, tokens, cur + by) + if not ok2: + errors.append(f"{path}: {err2}") + elif op == "append": + value = op_spec.get("value") + ok, cur, err = _navigate(state, tokens) + if not ok: + # create empty list, then append + ok2, err2 = _set_path(state, tokens, [value]) + if not ok2: + errors.append(f"{path}: {err2}") + else: + if not isinstance(cur, list): + errors.append(f"{path}: cannot append to non-list") + else: + cur.append(value) + elif op == "remove": + ok, err = _remove_path(state, tokens) + if not ok: + errors.append(f"{path}: {err}") + else: + # Direct value assignment + ok, err = _set_path(state, tokens, op_spec) + if not ok: + errors.append(f"{path}: {err}") + return state, errors + + +def validate_world(world_dict: dict[str, Any]) -> tuple[bool, list[str]]: + """Top-level validation of a World dict. + + Checks: presence of required keys, types of basic fields, and that + `environment` validates against `environment_schema`. + """ + errors: list[str] = [] + required_top = ["name", "language", "schemas", "environment_schema", "environment"] + for k in required_top: + if k not in world_dict: + errors.append(f"Missing required world field: {k}") + + # environment must validate against environment_schema + env_schema = world_dict.get("environment_schema", []) + env = world_dict.get("environment", {}) + if env_schema and env: + ok, env_errors = validate_state(env, env_schema) + if not ok: + errors.extend(env_errors) + + # plot_rails structure + pr = world_dict.get("plot_rails") or {} + for k in ("hooks", "current_goals", "completed_goals"): + if k not in pr: + errors.append(f"plot_rails missing key: {k}") + elif not isinstance(pr[k], list): + errors.append(f"plot_rails.{k} must be list") + + # current_time format + ct = world_dict.get("current_time") + if ct: + from app.core.time_utils import GameTime + + try: + GameTime.parse(ct) + except ValueError as e: + errors.append(str(e)) + + return (len(errors) == 0, errors) diff --git a/app/core/time_utils.py b/app/core/time_utils.py new file mode 100644 index 0000000..467b552 --- /dev/null +++ b/app/core/time_utils.py @@ -0,0 +1,148 @@ +"""Helpers for parsing/advancing in-game time strings. + +Time format: `day_D_hour_H[_min_M]` (optionally with `year_Y_` prefix). + +Examples: +- `day_1_hour_8` -> (1, 8, 0) +- `day_3_hour_14_min_30` -> (3, 14, 30) +- `year_2_day_5_hour_12` -> (2, 5, 12, 0) + +Delta format: `[year_Y][days_D][hours_H][min_M]` +Examples: `hours_2_min_30`, `days_1`, `min_15`, `days_3_hours_2` +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Iterable + +_TIME_RE = re.compile( + r"^(?:year_(\d+)_)?day_(\d+)_hour_(\d+)(?:_min_(\d+))?$" +) +_DELTA_RE = re.compile( + r"^(?:(?:year_(\d+)_)?(?:days_(\d+)_)?(?:hours_(\d+)_)?(?:min_(\d+))?)$" +) + + +@dataclass(frozen=True) +class GameTime: + year: int = 1 + day: int = 1 + hour: int = 0 + minute: int = 0 + + def __post_init__(self): + if self.year < 1 or self.day < 1 or self.hour < 0 or self.minute < 0: + raise ValueError(f"Invalid GameTime: {self}") + if self.hour > 23: + raise ValueError(f"Hour out of range: {self.hour}") + if self.minute > 59: + raise ValueError(f"Minute out of range: {self.minute}") + + @classmethod + def parse(cls, s: str) -> "GameTime": + m = _TIME_RE.match(s.strip()) + if not m: + raise ValueError(f"Invalid time string: {s!r}") + year = int(m.group(1)) if m.group(1) else 1 + day = int(m.group(2)) + hour = int(m.group(3)) + minute = int(m.group(4)) if m.group(4) else 0 + return cls(year=year, day=day, hour=hour, minute=minute) + + def to_string(self) -> str: + parts = [] + if self.year != 1: + parts.append(f"year_{self.year}") + parts.append(f"day_{self.day}") + parts.append(f"hour_{self.hour}") + if self.minute: + parts.append(f"min_{self.minute}") + return "_".join(parts) + + def total_minutes(self, hours_in_day: int = 24) -> int: + """Total minutes since the start of year 1, day 1, hour 0.""" + return ( + (self.year - 1) * 365 * hours_in_day * 60 + + (self.day - 1) * hours_in_day * 60 + + self.hour * 60 + + self.minute + ) + + @classmethod + def from_total_minutes(cls, total: int, hours_in_day: int = 24) -> "GameTime": + year_len = 365 * hours_in_day * 60 + day_len = hours_in_day * 60 + year = total // year_len + 1 + rem = total % year_len + day = rem // day_len + 1 + rem = rem % day_len + hour = rem // 60 + minute = rem % 60 + return cls(year=year, day=day, hour=hour, minute=minute) + + +def parse_delta(delta: str) -> tuple[int, int, int, int]: + """Parse a delta string, return (years, days, hours, minutes). + + Accepted formats: + - `hours_2`, `min_30`, `days_1`, `year_2` + - `hours_2_min_30`, `days_3_hours_4`, `year_1_days_5_hours_2_min_15` + - `hours_2min_30` (no separator between components — also accepted) + """ + s = delta.strip() + if not s: + raise ValueError("Empty delta string") + parts: dict[str, int] = {"year": 0, "days": 0, "hours": 0, "min": 0} + # Use finditer to walk the string and ensure full coverage + pos = 0 + matches = list(re.finditer(r"(year|days|hours|min)_(\d+)", s)) + if not matches: + raise ValueError(f"Invalid delta string: {delta!r}") + for m in matches: + # Between matches, only underscores are allowed + gap = s[pos:m.start()] + if any(c != "_" for c in gap): + raise ValueError(f"Invalid delta string: {delta!r}") + parts[m.group(1)] += int(m.group(2)) + pos = m.end() + # Trailing chars must also be underscores only + trailing = s[pos:] + if any(c != "_" for c in trailing): + raise ValueError(f"Invalid delta string: {delta!r}") + return (parts["year"], parts["days"], parts["hours"], parts["min"]) + + +def advance_time(current: str, delta: str, time_schema: dict | None = None) -> str: + """Advance `current` time string by `delta`. Returns new time string.""" + schema = time_schema or {"hours_in_day": 24} + hours_in_day = int(schema.get("hours_in_day", 24)) + gt = GameTime.parse(current) + y, d, h, mn = parse_delta(delta) + total = gt.total_minutes(hours_in_day) + ( + y * 365 * hours_in_day * 60 + d * hours_in_day * 60 + h * 60 + mn + ) + new_gt = GameTime.from_total_minutes(total, hours_in_day) + return new_gt.to_string() + + +def time_le(a: str, b: str) -> bool: + """Return True if time `a` <= time `b`.""" + ga, gb = GameTime.parse(a), GameTime.parse(b) + return ga.total_minutes() <= gb.total_minutes() + + +def summarize_schemas(schemas: Iterable[dict]) -> str: + """Render a compact human-readable summary of entity schemas for LLM prompts.""" + lines: list[str] = [] + for s in schemas: + type_name = s.get("type", "?") + verbose = s.get("verbose", type_name) + props = s.get("properties", []) + prop_str = ", ".join( + f"{p.get('name')}:{p.get('type')}" + ("*" if p.get("required") else "") + for p in props + ) + lines.append(f"- {verbose} ({type_name}): {prop_str}") + return "\n".join(lines) if lines else "(no schemas)" diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..65b0888 --- /dev/null +++ b/app/db.py @@ -0,0 +1,70 @@ +"""Async SQLAlchemy database session setup.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase + +from app.config import get_settings + + +class Base(DeclarativeBase): + """Declarative base for all ORM models.""" + + +_engine = None +_sessionmaker = None + + +def get_engine(): + """Lazy-create the global async engine.""" + global _engine + if _engine is None: + cfg = get_settings() + _engine = create_async_engine( + cfg.database_url, + echo=cfg.db_echo, + pool_size=cfg.db_pool_size, + max_overflow=cfg.db_max_overflow, + future=True, + ) + return _engine + + +def get_sessionmaker() -> async_sessionmaker[AsyncSession]: + """Lazy-create the global session factory.""" + global _sessionmaker + if _sessionmaker is None: + _sessionmaker = async_sessionmaker( + get_engine(), + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, + ) + return _sessionmaker + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """FastAPI dependency: yields an async session and rolls back on error.""" + sm = get_sessionmaker() + async with sm() as session: + try: + yield session + except Exception: + await session.rollback() + raise + + +async def dispose_engine() -> None: + """Dispose engine on application shutdown.""" + global _engine, _sessionmaker + if _engine is not None: + await _engine.dispose() + _engine = None + _sessionmaker = None diff --git a/app/engine/__init__.py b/app/engine/__init__.py new file mode 100644 index 0000000..ca2a1f7 --- /dev/null +++ b/app/engine/__init__.py @@ -0,0 +1 @@ +"""Engine layer — game logic (orchestrator, world builder/editor, tools, context).""" diff --git a/app/engine/context.py b/app/engine/context.py new file mode 100644 index 0000000..2de668f --- /dev/null +++ b/app/engine/context.py @@ -0,0 +1,180 @@ +"""Context manager — builds the LLM message list per stage. + +Implements the compression strategy from §10.3 of the TDD: +- If history > threshold, prepend the latest summary as a system message. +- Truncate to last N guaranteed messages. +- Optionally include RAG results. +""" + +from __future__ import annotations + +import json +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logging import get_logger +from app.core.settings_service import get_setting +from app.core.time_utils import summarize_schemas +from app.models import StoryEntry, Step, World +from app.prompts.registry import get_prompt + +_logger = get_logger(__name__) + + +def _scene_text_truncate(text: str, max_tokens: int) -> str: + """Crude truncation: ~4 chars per token.""" + max_chars = max_tokens * 4 + if len(text) <= max_chars: + return text + return text[:max_chars] + "…" + + +async def build_orchestrator_phase1_context( + *, + db: AsyncSession, + world: World, + player_action: str, + settings: dict[str, Any], +) -> list[dict[str, Any]]: + """Build messages list for orchestrator Phase 1.""" + guaranteed = int(settings.get("context.guaranteed_messages", 10)) + threshold = int(settings.get("context.compression_threshold_messages", 20)) + scene_trunc = int(settings.get("context.scene_text_truncate_tokens", 500)) + + # Fetch recent steps (most recent first) + recent_steps = list( + reversed( + ( + await db.execute( + select(Step) + .where(Step.world_id == world.id, Step.deleted_at.is_(None)) + .order_by(Step.sequence_number.desc()) + .limit(max(threshold, guaranteed) + 1) + ) + ).scalars().all() + ) + ) + + # Pull latest summary if available + summary_text: str | None = None + if len(recent_steps) > threshold: + latest_summary = ( + await db.execute( + select(StoryEntry) + .where( + StoryEntry.world_id == world.id, + StoryEntry.entry_type == "event", + StoryEntry.metadata_["type"].as_string() == "summary", + ) + .order_by(StoryEntry.created_at.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if latest_summary: + summary_text = latest_summary.content + + # Build the message list + sys_prompt = get_prompt("orchestrator_phase1", "en").format( + world_name=world.name, + rules="\n".join(f"- {r}" for r in (world.rules or [])), + schemas_summary=summarize_schemas(world.schemas or []), + environment_json=json.dumps(world.environment or {}, ensure_ascii=False, indent=2), + plot_rails_json=json.dumps(world.plot_rails or {}, ensure_ascii=False, indent=2), + current_time=world.current_time, + recent_history=_format_recent_history( + recent_steps[-guaranteed:], scene_trunc + ), + max_substeps=settings.get("game.max_substeps_per_iteration", 8), + language=world.language, + player_action=player_action, + ) + messages: list[dict[str, Any]] = [{"role": "system", "content": sys_prompt}] + if summary_text: + messages.append({ + "role": "system", + "content": f"Summary of earlier events:\n{summary_text}", + }) + # Recent steps as user/assistant pairs + for s in recent_steps[-guaranteed:]: + messages.append({"role": "user", "content": s.player_action}) + if s.scene_text: + messages.append({"role": "assistant", "content": s.scene_text}) + # Current action + messages.append({"role": "user", "content": player_action}) + return messages + + +def _format_recent_history(steps: list[Step], scene_trunc: int) -> str: + if not steps: + return "(no recent history)" + lines: list[str] = [] + for s in steps[-5:]: # only show last 5 in the prompt + text = _scene_text_truncate(s.scene_text or "(no scene)", scene_trunc) + lines.append(f"[step {s.sequence_number}] {s.player_action}\n → {text}") + return "\n".join(lines) + + +async def build_orchestrator_phase2_context( + *, + db: AsyncSession, + world: World, + player_action: str, + plan: str, + summary: list[dict[str, Any]], + settings: dict[str, Any], +) -> list[dict[str, Any]]: + """Build messages list for orchestrator Phase 2 (writer).""" + sys_prompt = get_prompt("orchestrator_phase2", "en").format( + world_name=world.name, + world_description=world.description or "", + language=world.language, + current_time=world.current_time, + player_action=player_action, + plan=plan, + summary_json=json.dumps(summary, ensure_ascii=False, indent=2), + environment_json=json.dumps(world.environment or {}, ensure_ascii=False, indent=2), + ) + return [ + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": "Write the scene and call submit_step."}, + ] + + +async def build_orchestrator_phase3_suggest_context( + *, + db: AsyncSession, + world: World, + scene_text: str, + settings: dict[str, Any], +) -> list[dict[str, Any]]: + sys_prompt = get_prompt("orchestrator_phase3_suggest", "en").format( + language=world.language, + scene_text=scene_text[:2000], + current_goals=", ".join((world.plot_rails or {}).get("current_goals", []) or ["(none)"]), + ) + return [ + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": "Suggest 1-3 next actions."}, + ] + + +async def build_summary_context( + *, + db: AsyncSession, + world: World, + old_steps: list[Step], + settings: dict[str, Any], +) -> list[dict[str, Any]]: + """Build messages list for the summary LLM call.""" + messages_json = json.dumps( + [{"action": s.player_action, "scene": s.scene_text} for s in old_steps], + ensure_ascii=False, + indent=2, + ) + sys_prompt = get_prompt("summary", "en").format(messages_json=messages_json) + return [ + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": "Summarize."}, + ] diff --git a/app/engine/game_master.py b/app/engine/game_master.py new file mode 100644 index 0000000..08901af --- /dev/null +++ b/app/engine/game_master.py @@ -0,0 +1,314 @@ +"""Game Master (orchestrator) — three-phase iteration engine. + +Phase 1: Planner + Executor (tool-calling loop until submit_plan) +Phase 2: Writer (single LLM call with submit_step tool) +Phase 3: Persist + Deferred triggers + Summary + Suggest actions +""" + +from __future__ import annotations + +import json +import uuid +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.llm import LlmClient, MockLlmClient +from app.core.logging import get_logger +from app.core.rag import rag_add +from app.core.settings_service import get_all_settings +from app.core.time_utils import advance_time, summarize_schemas +from app.engine.context import ( + build_orchestrator_phase1_context, + build_orchestrator_phase2_context, + build_orchestrator_phase3_suggest_context, + build_summary_context, +) +from app.engine.sse import SseEmitter +from app.engine.tools.base import ToolContext, get_registry +from app.engine.world_builder import _run_tool_loop +from app.models import DeferredTrigger, Step, StoryEntry, World + +_logger = get_logger(__name__) + + +async def run_iteration( + *, + db: AsyncSession, + world: World, + step: Step, + llm: LlmClient | MockLlmClient, + sse: SseEmitter, +) -> None: + """Run the full three-phase orchestrator iteration for a single step.""" + settings = await get_all_settings(db) + try: + # ============ Phase 1 ============ + await sse.emit("phase_start", {"phase": 1, "name": "planner_executor"}) + messages = await build_orchestrator_phase1_context( + db=db, world=world, player_action=step.player_action, settings=settings, + ) + phase1_result = await _run_tool_loop( + db=db, world=world, llm=llm, sse=sse, + stage="orchestrator_phase1", + system_prompt=messages[0]["content"], + terminal_tool="submit_plan", + max_substeps=int(settings.get("game.max_substeps_per_iteration", 8)), + settings=settings, + ) + await sse.emit("phase_end", {"phase": 1, "duration_ms": 0}) + if not phase1_result or not phase1_result.get("ok"): + # Force-completion: synthesize a minimal plan + phase1_result = { + "ok": True, + "data": { + "plan": "The action was processed but no explicit plan was submitted.", + "summary": [], + "offscreen_events": [], + }, + } + plan = phase1_result["data"].get("plan", "") + summary = phase1_result["data"].get("summary", []) + offscreen_events = phase1_result["data"].get("offscreen_events", []) + + # Persist tool_calls_summary on the step + step.tool_calls_summary = summary + await db.commit() + + # ============ Phase 2: Writer ============ + await sse.emit("phase_start", {"phase": 2, "name": "writer"}) + messages = await build_orchestrator_phase2_context( + db=db, world=world, player_action=step.player_action, + plan=plan, summary=summary, settings=settings, + ) + registry = get_registry() + ctx = ToolContext(db=db, world=world, step_id=step.id, stage="orchestrator_phase2", + sse_emitter=sse.emit) + tools = registry.to_openai_format("orchestrator_phase2") + phase2_msg: dict[str, Any] = {} + for retry in range(3): + resp = await llm.complete( + stage="orchestrator_phase2", + messages=messages, + tools=tools, + temperature=float(settings.get("llm.temperature_writer", 0.85)), + max_tokens=int(settings.get("llm.max_tokens", 2048)), + world_id=world.id, step_id=step.id, session=db, + ) + phase2_msg = resp.get("message", {}) + tcs = phase2_msg.get("tool_calls") or [] + if tcs: + # Execute submit_step + for tc in tcs: + fn = tc.get("function", {}) + if fn.get("name") == "submit_step": + try: + args = json.loads(fn.get("arguments") or "{}") + except json.JSONDecodeError: + args = {} + result = await registry.execute("submit_step", args, ctx) + if result.ok: + scene_text = result.data.get("scene_text", "") + delta_time = result.data.get("delta_time", "hours_1") + step.scene_text = scene_text + step.scene_delta_time = delta_time + await sse.emit("scene_complete", { + "text": scene_text, "delta_time": delta_time, + }) + break + if step.scene_text: + break + # Retry + messages.append(phase2_msg) + messages.append({ + "role": "user", + "content": "You MUST call submit_step with scene_text and delta_time.", + }) + else: + await sse.error("writer_no_submit", "Writer failed to call submit_step after 3 retries") + step.status = "failed" + await db.commit() + return + + await sse.emit("phase_end", {"phase": 2, "duration_ms": 0}) + + # ============ Phase 3 ============ + await sse.emit("phase_start", {"phase": 3, "name": "persist_triggers_summary_suggest"}) + # 3.0 Persist + step.status = "completed" + world.last_played_at = datetime.now(timezone.utc) + world.current_time = advance_time( + world.current_time, step.scene_delta_time or "hours_1", world.time_schema + ) + await db.commit() + + # 3.1 Deferred triggers + if settings.get("game.deferred_triggers_enabled", True): + await _process_deferred_triggers( + db=db, world=world, step=step, llm=llm, sse=sse, settings=settings, + ) + + # 3.2 Summary (if history is too long) + await _maybe_generate_summary( + db=db, world=world, step=step, llm=llm, sse=sse, settings=settings, + ) + + # 3.3 Suggest actions + suggest_msgs = await build_orchestrator_phase3_suggest_context( + db=db, world=world, scene_text=step.scene_text or "", settings=settings, + ) + suggest_tools = registry.to_openai_format("orchestrator_phase3_suggest") + for retry in range(2): + resp = await llm.complete( + stage="orchestrator_phase3_suggest", + messages=suggest_msgs, + tools=suggest_tools, + temperature=0.8, + max_tokens=512, + world_id=world.id, step_id=step.id, session=db, + ) + msg = resp.get("message", {}) + tcs = msg.get("tool_calls") or [] + for tc in tcs: + fn = tc.get("function", {}) + if fn.get("name") == "suggest_actions": + try: + args = json.loads(fn.get("arguments") or "{}") + except json.JSONDecodeError: + args = {} + result = await registry.execute("suggest_actions", args, ctx) + if result.ok: + step.suggested_actions = result.data.get("actions", []) + await sse.emit("suggested_actions", {"actions": step.suggested_actions}) + break + if step.suggested_actions: + break + suggest_msgs.append(msg) + suggest_msgs.append({"role": "user", "content": "Call suggest_actions with 1-3 actions."}) + + await db.commit() + await sse.emit("iteration_complete", { + "step_id": str(step.id), "sequence_number": step.sequence_number, + }) + await sse.done({"step_id": str(step.id), "status": "completed"}) + except Exception as e: # noqa: BLE001 + _logger.exception("orchestrator_failed", step_id=str(step.id), error=str(e)) + step.status = "failed" + await db.commit() + await sse.error("internal_error", str(e)) + + +async def _process_deferred_triggers( + *, + db: AsyncSession, + world: World, + step: Step, + llm: LlmClient | MockLlmClient, + sse: SseEmitter, + settings: dict[str, Any], +) -> None: + """Fire all deferred triggers whose fire_at <= current_time.""" + from app.core.time_utils import time_le + + triggers = ( + await db.execute( + select(DeferredTrigger).where( + DeferredTrigger.world_id == world.id, + DeferredTrigger.is_fired.is_(False), + ) + ) + ).scalars().all() + fired = 0 + for trig in triggers: + try: + if not time_le(trig.fire_at, world.current_time): + continue + except Exception: # noqa: BLE001 + continue + # Simple firing: append a note to scene_text + summary = f"\n\n[Offscreen event: {trig.event_type} — payload: {json.dumps(trig.payload, ensure_ascii=False)}]" + if step.scene_text: + step.scene_text += summary + else: + step.scene_text = summary + trig.is_fired = True + trig.fired_at = datetime.now(timezone.utc) + await db.flush() + await sse.emit("trigger_fired", { + "trigger_id": str(trig.id), "event_type": trig.event_type, + "summary": summary.strip(), + }) + fired += 1 + # Persist the trigger event as a story entry + await rag_add( + db=db, world_id=world.id, + content=f"Deferred trigger fired: {trig.event_type} at {trig.fire_at}", + entry_type="event", + metadata={"trigger_id": str(trig.id), "step_id": str(step.id)}, + step_id=step.id, + ) + if fired: + await db.commit() + + +async def _maybe_generate_summary( + *, + db: AsyncSession, + world: World, + step: Step, + llm: LlmClient | MockLlmClient, + sse: SseEmitter, + settings: dict[str, Any], +) -> None: + """Generate a summary if recent step count exceeds the threshold.""" + threshold = int(settings.get("context.compression_threshold_messages", 20)) + guaranteed = int(settings.get("context.guaranteed_messages", 10)) + recent_steps = list( + reversed( + ( + await db.execute( + select(Step) + .where(Step.world_id == world.id, Step.deleted_at.is_(None)) + .order_by(Step.sequence_number.desc()) + .limit(threshold + 1) + ) + ).scalars().all() + ) + ) + if len(recent_steps) <= threshold: + return + old_steps = recent_steps[:-guaranteed] + if not old_steps: + return + messages = await build_summary_context( + db=db, world=world, old_steps=old_steps, settings=settings, + ) + resp = await llm.complete( + stage="orchestrator_phase3_summary", + messages=messages, + temperature=0.3, + max_tokens=1024, + world_id=world.id, step_id=step.id, session=db, + ) + summary_text = resp.get("message", {}).get("content", "") + if not summary_text: + return + # Store as a story entry + se = StoryEntry( + world_id=world.id, + content=summary_text, + entry_type="event", + metadata_={ + "type": "summary", + "step_range": [old_steps[0].sequence_number, old_steps[-1].sequence_number], + }, + embedding_status="pending", + ) + db.add(se) + await db.commit() + await sse.emit("summary_generated", { + "summary_id": str(se.id), + "message_range": [old_steps[0].sequence_number, old_steps[-1].sequence_number], + }) diff --git a/app/engine/sse.py b/app/engine/sse.py new file mode 100644 index 0000000..e912619 --- /dev/null +++ b/app/engine/sse.py @@ -0,0 +1,84 @@ +"""SSE event emitter — wraps sse-starlette to emit typed events.""" + +from __future__ import annotations + +import asyncio +import json +import uuid +from collections.abc import AsyncIterator +from typing import Any + +from app.core.logging import get_logger + +_logger = get_logger(__name__) + + +class SseEmitter: + """Async queue-based SSE emitter. + + Usage: + emitter = SseEmitter() + async with emitter.stream() as stream: + async for event in stream: + yield event + + In a producer task: + await emitter.emit("tool_call", {...}) + await emitter.done({"result": "ok"}) + """ + + def __init__(self) -> None: + self._queue: asyncio.Queue[tuple[str, str, str] | None] = asyncio.Queue() + # (event_type, data_json, event_id) + self._event_counter = 0 + self._closed = False + + async def emit(self, event_type: str, data: Any) -> None: + if self._closed: + return + self._event_counter += 1 + event_id = f"evt_{self._event_counter}" + try: + data_str = json.dumps(data, ensure_ascii=False, default=str) + except (TypeError, ValueError): + data_str = json.dumps({"error": "serialization_failed"}) + await self._queue.put((event_type, data_str, event_id)) + + async def ping(self) -> None: + await self.emit("ping", {"ts": _now_iso()}) + + async def done(self, result: Any = None) -> None: + await self.emit("done", result if result is not None else {}) + await self._queue.put(None) # sentinel + self._closed = True + + async def error(self, code: str, message: str, details: Any = None) -> None: + payload: dict[str, Any] = {"code": code, "message": message} + if details is not None: + payload["details"] = details + await self.emit("error", payload) + await self._queue.put(None) + self._closed = True + + async def stream(self) -> AsyncIterator[dict[str, str]]: + """Yield SSE-formatted dicts until the emitter is closed.""" + try: + while True: + item = await self._queue.get() + if item is None: + break + event_type, data_str, event_id = item + yield { + "event": event_type, + "data": data_str, + "id": event_id, + } + except asyncio.CancelledError: + _logger.info("sse_stream_cancelled") + raise + + +def _now_iso() -> str: + from datetime import datetime, timezone + + return datetime.now(timezone.utc).isoformat() diff --git a/app/engine/tools/__init__.py b/app/engine/tools/__init__.py new file mode 100644 index 0000000..a295834 --- /dev/null +++ b/app/engine/tools/__init__.py @@ -0,0 +1 @@ +"""Tools package — game tools, interaction tools, schema tools.""" diff --git a/app/engine/tools/base.py b/app/engine/tools/base.py new file mode 100644 index 0000000..ed44e4d --- /dev/null +++ b/app/engine/tools/base.py @@ -0,0 +1,231 @@ +"""Base types for tool system: Tool, ToolContext, ToolResult, ToolRegistry.""" + +from __future__ import annotations + +import abc +import uuid +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logging import get_logger +from app.core.state_validator import apply_patch +from app.models import Entity, StepToolCall, World + +_logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- # +# Context & Result +# --------------------------------------------------------------------------- # +@dataclass +class ToolContext: + """Per-iteration context passed to every tool call.""" + + db: AsyncSession + world: World + user_id: uuid.UUID | None = None + step_id: uuid.UUID | None = None + stage: str = "" + sse_emitter: Any = None # callable: async (event, data) -> None + pending_state_changes: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ToolResult: + ok: bool + data: dict[str, Any] = field(default_factory=dict) + message: str = "" + error_code: str = "" + error_message: str = "" + + def to_dict(self) -> dict[str, Any]: + if self.ok: + return {"ok": True, "data": self.data, "message": self.message} + return { + "ok": False, + "error": {"code": self.error_code, "message": self.error_message}, + } + + +# --------------------------------------------------------------------------- # +# Tool base class +# --------------------------------------------------------------------------- # +class Tool(abc.ABC): + """Abstract base for all tools.""" + + name: str = "" + category: str = "game" # game | interaction | schema + stages: set[str] = set() # which stages can use this tool + description: str = "" + parameters_schema: dict[str, Any] = {} + + @abc.abstractmethod + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + """Run the tool. Must be idempotent within a single transaction.""" + + def to_openai_format(self) -> dict[str, Any]: + """Serialize to OpenAI tools format.""" + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": self.parameters_schema, + }, + } + + +# --------------------------------------------------------------------------- # +# Registry +# --------------------------------------------------------------------------- # +class ToolRegistry: + """Holds all registered tools and dispatches calls.""" + + def __init__(self) -> None: + self._tools: dict[str, Tool] = {} + + def register(self, tool: Tool) -> None: + if not tool.name: + raise ValueError("Tool name is required") + if tool.name in self._tools: + raise ValueError(f"Tool {tool.name} already registered") + self._tools[tool.name] = tool + + def get(self, name: str) -> Tool | None: + return self._tools.get(name) + + def list_for_stage(self, stage: str) -> list[Tool]: + """Return all tools available at the given stage.""" + return [t for t in self._tools.values() if stage in t.stages or "*" in t.stages] + + def to_openai_format(self, stage: str) -> list[dict[str, Any]]: + return [t.to_openai_format() for t in self.list_for_stage(stage)] + + async def execute( + self, + name: str, + arguments: dict[str, Any], + ctx: ToolContext, + ) -> ToolResult: + """Execute a tool by name. Logs to step_tool_calls and emits SSE.""" + from sqlalchemy import select as sa_select + + tool = self.get(name) + executed_at = datetime.now(timezone.utc) + if tool is None: + result = ToolResult( + ok=False, + error_code="unknown_tool", + error_message=f"Tool {name!r} is not registered", + ) + else: + try: + result = await tool.execute(arguments, ctx) + except Exception as e: # noqa: BLE001 + _logger.exception("tool_execution_failed", tool=name, error=str(e)) + result = ToolResult( + ok=False, + error_code="tool_exception", + error_message=str(e), + ) + + # Log to step_tool_calls (if we have a step_id) + if ctx.step_id is not None: + try: + ctx.db.add( + StepToolCall( + step_id=ctx.step_id, + tool_name=name, + arguments=arguments, + result=result.to_dict(), + is_success=result.ok, + executed_at=executed_at, + ) + ) + await ctx.db.flush() + except Exception as e: # noqa: BLE001 + _logger.error("tool_log_failed", tool=name, error=str(e)) + + # Emit SSE + if ctx.sse_emitter is not None: + try: + await ctx.sse_emitter( + "tool_call", + { + "tool": name, + "arguments": arguments, + "result": result.to_dict(), + "is_success": result.ok, + }, + ) + except Exception as e: # noqa: BLE001 + _logger.warning("sse_tool_call_failed", tool=name, error=str(e)) + + return result + + +# --------------------------------------------------------------------------- # +# Helpers used by entity_* tools +# --------------------------------------------------------------------------- # +async def get_entity_by_query( + db: AsyncSession, world_id: uuid.UUID, query: Any +) -> Entity | None: + """Resolve entity by UUID string or by {entity_type, name}.""" + from sqlalchemy import select as sa_select + + if isinstance(query, str): + try: + eid = uuid.UUID(query) + except ValueError: + return None + return ( + await db.execute( + sa_select(Entity).where( + Entity.id == eid, Entity.world_id == world_id + ) + ) + ).scalar_one_or_none() + elif isinstance(query, dict): + et = query.get("entity_type") + nm = query.get("name") + if not et or not nm: + return None + return ( + await db.execute( + sa_select(Entity).where( + Entity.world_id == world_id, + Entity.entity_type == et, + Entity.name == nm, + Entity.deleted_at.is_(None), + ) + ) + ).scalar_one_or_none() + return None + + +def apply_env_patch(environment: dict, patch: dict) -> tuple[dict, list[str]]: + """Wrapper around state_validator.apply_patch for environment dicts.""" + return apply_patch(environment, patch) + + +# Singleton registry (instantiated in `app.engine.tools.__init__`) +_registry: ToolRegistry | None = None + + +def get_registry() -> ToolRegistry: + global _registry + if _registry is None: + from app.engine.tools.register_all import build_default_registry + + _registry = build_default_registry() + return _registry + + +def reset_registry() -> None: + """Reset the cached registry — used in tests.""" + global _registry + _registry = None diff --git a/app/engine/tools/game.py b/app/engine/tools/game.py new file mode 100644 index 0000000..30d1e8e --- /dev/null +++ b/app/engine/tools/game.py @@ -0,0 +1,993 @@ +"""Game tools — entity CRUD, environment manipulation, RAG, triggers, calc, etc.""" + +from __future__ import annotations + +import random +import re +import uuid +from typing import Any + +from sqlalchemy import select + +from app.core.logging import get_logger +from app.core.state_validator import apply_patch, validate_state +from app.engine.tools.base import Tool, ToolContext, ToolResult, get_entity_by_query +from app.models import Entity + +_logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- # +# entity_create +# --------------------------------------------------------------------------- # +class EntityCreateTool(Tool): + name = "entity_create" + category = "game" + stages = {"world_builder", "world_editor", "orchestrator_phase1", "subagent", "intro_scene"} + description = ( + "Create a new entity in the current world. The entity_type must exist in " + "world.schemas. The data must conform to the schema for that type." + ) + parameters_schema = { + "type": "object", + "required": ["entity_type", "name", "data"], + "properties": { + "entity_type": { + "type": "string", + "description": "Type from world.schemas (character, item, location, ...)", + }, + "name": { + "type": "string", + "description": "Entity name (unique within (world_id, entity_type))", + }, + "data": { + "type": "object", + "description": "Full entity data per schema", + }, + "add_to_environment": { + "type": "boolean", + "default": False, + "description": "Add to environment for fast LLM access", + }, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + et = arguments.get("entity_type") + nm = arguments.get("name") + data = arguments.get("data") or {} + add_env = arguments.get("add_to_environment", False) + if not et or not nm: + return ToolResult( + ok=False, + error_code="validation_error", + error_message="entity_type and name are required", + ) + # Validate type exists in schemas + schemas = ctx.world.schemas or [] + type_names = {s.get("type") for s in schemas} + if et not in type_names: + return ToolResult( + ok=False, + error_code="unknown_entity_type", + error_message=f"Entity type {et!r} not in world.schemas", + ) + # Name uniqueness within (world, type) + existing = ( + await ctx.db.execute( + select(Entity).where( + Entity.world_id == ctx.world.id, + Entity.entity_type == et, + Entity.name == nm, + Entity.deleted_at.is_(None), + ) + ) + ).scalar_one_or_none() + if existing is not None: + return ToolResult( + ok=False, + error_code="name_conflict", + error_message=f"Entity {et}/{nm!r} already exists", + ) + + entity = Entity( + world_id=ctx.world.id, + entity_type=et, + name=nm, + data=data, + is_in_environment=add_env, + embedding_status="pending", + ) + ctx.db.add(entity) + await ctx.db.flush() + if add_env: + env = dict(ctx.world.environment or {}) + env.setdefault("entities", []).append( + {"id": str(entity.id), "entity_type": et, "name": nm} + ) + ctx.world.environment = env + return ToolResult( + ok=True, + data={"entity_id": str(entity.id)}, + message=f"Created {et} {nm!r}", + ) + + +# --------------------------------------------------------------------------- # +# entity_get +# --------------------------------------------------------------------------- # +class EntityGetTool(Tool): + name = "entity_get" + category = "game" + stages = { + "world_builder", + "world_editor", + "orchestrator_phase1", + "subagent", + "intro_scene", + } + description = "Get an entity by id or by {entity_type, name}." + parameters_schema = { + "type": "object", + "required": ["query"], + "properties": { + "query": { + "oneOf": [ + {"type": "string", "description": "entity_id (UUID)"}, + { + "type": "object", + "properties": { + "entity_type": {"type": "string"}, + "name": {"type": "string"}, + }, + "required": ["entity_type", "name"], + }, + ] + } + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + q = arguments.get("query") + ent = await get_entity_by_query(ctx.db, ctx.world.id, q) + if ent is None or ent.deleted_at is not None: + return ToolResult( + ok=False, + error_code="not_found", + error_message="Entity not found", + ) + return ToolResult( + ok=True, + data={ + "id": str(ent.id), + "entity_type": ent.entity_type, + "name": ent.name, + "data": ent.data, + "is_in_environment": ent.is_in_environment, + }, + message=f"Got {ent.entity_type} {ent.name!r}", + ) + + +# --------------------------------------------------------------------------- # +# entity_list +# --------------------------------------------------------------------------- # +class EntityListTool(Tool): + name = "entity_list" + category = "game" + stages = { + "world_builder", + "world_editor", + "orchestrator_phase1", + "subagent", + "intro_scene", + } + description = "List entities in the world, optionally filtered." + parameters_schema = { + "type": "object", + "properties": { + "entity_type": {"type": "string"}, + "in_environment_only": {"type": "boolean", "default": False}, + "name_contains": {"type": "string"}, + "limit": {"type": "integer", "default": 50, "max": 200}, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + stmt = select(Entity).where( + Entity.world_id == ctx.world.id, Entity.deleted_at.is_(None) + ) + et = arguments.get("entity_type") + if et: + stmt = stmt.where(Entity.entity_type == et) + if arguments.get("in_environment_only"): + stmt = stmt.where(Entity.is_in_environment.is_(True)) + nc = arguments.get("name_contains") + if nc: + stmt = stmt.where(Entity.name.ilike(f"%{nc}%")) + limit = min(arguments.get("limit", 50), 200) + stmt = stmt.limit(limit) + rows = (await ctx.db.execute(stmt)).scalars().all() + return ToolResult( + ok=True, + data={ + "items": [ + { + "id": str(r.id), + "entity_type": r.entity_type, + "name": r.name, + "data": r.data, + "is_in_environment": r.is_in_environment, + } + for r in rows + ], + "count": len(rows), + }, + message=f"Listed {len(rows)} entities", + ) + + +# --------------------------------------------------------------------------- # +# entity_update +# --------------------------------------------------------------------------- # +class EntityUpdateTool(Tool): + name = "entity_update" + category = "game" + stages = {"world_editor", "orchestrator_phase1", "subagent"} + description = "Update entity fields via JSON-patch." + parameters_schema = { + "type": "object", + "required": ["entity_id", "patch"], + "properties": { + "entity_id": {"type": "string"}, + "patch": { + "type": "object", + "description": "JSON-patch: {field_path: new_value | {op, by}}", + }, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + eid = arguments.get("entity_id") + patch = arguments.get("patch") or {} + try: + ent_uuid = uuid.UUID(eid) + except (ValueError, TypeError): + return ToolResult( + ok=False, error_code="validation_error", error_message="Invalid entity_id" + ) + ent = ( + await ctx.db.execute( + select(Entity).where( + Entity.id == ent_uuid, + Entity.world_id == ctx.world.id, + Entity.deleted_at.is_(None), + ) + ) + ).scalar_one_or_none() + if ent is None: + return ToolResult( + ok=False, error_code="not_found", error_message="Entity not found" + ) + new_data, errors = apply_patch(ent.data or {}, patch) + if errors: + return ToolResult( + ok=False, + error_code="validation_error", + error_message="; ".join(errors), + ) + ent.data = new_data + await ctx.db.flush() + return ToolResult( + ok=True, + data={"applied_paths": list(patch.keys())}, + message=f"Updated {ent.entity_type} {ent.name!r}", + ) + + +# --------------------------------------------------------------------------- # +# entity_delete +# --------------------------------------------------------------------------- # +class EntityDeleteTool(Tool): + name = "entity_delete" + category = "game" + stages = {"world_editor", "orchestrator_phase1", "subagent"} + description = "Soft-delete an entity." + parameters_schema = { + "type": "object", + "required": ["entity_id"], + "properties": { + "entity_id": {"type": "string"}, + "reason": {"type": "string"}, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + eid = arguments.get("entity_id") + try: + ent_uuid = uuid.UUID(eid) + except (ValueError, TypeError): + return ToolResult( + ok=False, error_code="validation_error", error_message="Invalid entity_id" + ) + ent = ( + await ctx.db.execute( + select(Entity).where( + Entity.id == ent_uuid, Entity.world_id == ctx.world.id + ) + ) + ).scalar_one_or_none() + if ent is None: + return ToolResult( + ok=False, error_code="not_found", error_message="Entity not found" + ) + from datetime import datetime, timezone + + ent.deleted_at = datetime.now(timezone.utc) + await ctx.db.flush() + return ToolResult( + ok=True, + data={"entity_id": str(ent.id)}, + message=f"Soft-deleted {ent.entity_type} {ent.name!r}", + ) + + +# --------------------------------------------------------------------------- # +# env_update / env_get +# --------------------------------------------------------------------------- # +class EnvUpdateTool(Tool): + name = "env_update" + category = "game" + stages = { + "world_builder", + "world_editor", + "orchestrator_phase1", + "subagent", + "intro_scene", + } + description = ( + "Apply a JSON-patch to environment. Patch is validated by state_validator." + ) + parameters_schema = { + "type": "object", + "required": ["patch"], + "properties": { + "patch": { + "type": "object", + "description": "Map field_path -> new_value | {op, by/value}. Ops: set, inc, dec, append, remove.", + } + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + patch = arguments.get("patch") or {} + new_env, errors = apply_patch(dict(ctx.world.environment or {}), patch) + if errors: + return ToolResult( + ok=False, + error_code="validation_error", + error_message="; ".join(errors), + ) + # Validate against environment_schema + ok, verrors = validate_state(new_env, ctx.world.environment_schema or []) + if not ok: + return ToolResult( + ok=False, + error_code="schema_violation", + error_message="; ".join(verrors), + ) + ctx.world.environment = new_env + await ctx.db.flush() + return ToolResult( + ok=True, + data={"applied_paths": list(patch.keys())}, + message="Environment updated", + ) + + +class EnvGetTool(Tool): + name = "env_get" + category = "game" + stages = { + "world_builder", + "world_editor", + "orchestrator_phase1", + "subagent", + "intro_scene", + } + description = "Get current environment value (or sub-path)." + parameters_schema = { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "e.g. 'player.stats' or 'plot_rails.current_goals'", + } + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + path = arguments.get("path") + env = ctx.world.environment or {} + if not path: + return ToolResult(ok=True, data=env, message="Full environment") + # Walk path + cur: Any = env + for part in path.split("."): + if isinstance(cur, dict) and part in cur: + cur = cur[part] + else: + return ToolResult( + ok=False, + error_code="not_found", + error_message=f"Path {path!r} not found in environment", + ) + return ToolResult(ok=True, data={"value": cur}, message=f"Value at {path!r}") + + +# --------------------------------------------------------------------------- # +# update_plot_rails +# --------------------------------------------------------------------------- # +class UpdatePlotRailsTool(Tool): + name = "update_plot_rails" + category = "game" + stages = { + "world_builder", + "world_editor", + "orchestrator_phase1", + "intro_scene", + } + description = "Add/remove hooks and goals in plot_rails." + parameters_schema = { + "type": "object", + "required": ["operation"], + "properties": { + "operation": { + "type": "string", + "enum": ["add_hook", "remove_hook", "add_goal", "remove_goal", "complete_goal"], + }, + "value": {"type": "string"}, + "index": {"type": "integer"}, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + op = arguments.get("operation") + val = arguments.get("value") + idx = arguments.get("index") + pr = dict(ctx.world.plot_rails or {}) + pr.setdefault("hooks", []) + pr.setdefault("current_goals", []) + pr.setdefault("completed_goals", []) + + if op == "add_hook": + if not val: + return ToolResult(ok=False, error_code="validation_error", + error_message="value required for add_hook") + pr["hooks"] = list(pr["hooks"]) + [val] + elif op == "remove_hook": + if idx is None or idx >= len(pr["hooks"]): + return ToolResult(ok=False, error_code="validation_error", + error_message="invalid index") + pr["hooks"] = [h for i, h in enumerate(pr["hooks"]) if i != idx] + elif op == "add_goal": + if not val: + return ToolResult(ok=False, error_code="validation_error", + error_message="value required for add_goal") + pr["current_goals"] = list(pr["current_goals"]) + [val] + elif op == "remove_goal": + if idx is None or idx >= len(pr["current_goals"]): + return ToolResult(ok=False, error_code="validation_error", + error_message="invalid index") + pr["current_goals"] = [g for i, g in enumerate(pr["current_goals"]) if i != idx] + elif op == "complete_goal": + if idx is None or idx >= len(pr["current_goals"]): + return ToolResult(ok=False, error_code="validation_error", + error_message="invalid index") + goal = pr["current_goals"][idx] + pr["current_goals"] = [g for i, g in enumerate(pr["current_goals"]) if i != idx] + pr["completed_goals"] = list(pr["completed_goals"]) + [goal] + else: + return ToolResult( + ok=False, + error_code="validation_error", + error_message=f"Unknown operation {op!r}", + ) + ctx.world.plot_rails = pr + await ctx.db.flush() + return ToolResult(ok=True, data=pr, message=f"plot_rails.{op} applied") + + +# --------------------------------------------------------------------------- # +# advance_time +# --------------------------------------------------------------------------- # +class AdvanceTimeTool(Tool): + name = "advance_time" + category = "game" + stages = {"orchestrator_phase1", "subagent"} + description = "Advance world time by a delta." + parameters_schema = { + "type": "object", + "required": ["delta"], + "properties": { + "delta": { + "type": "string", + "description": "Format: [year_Y][days_D][hours_H][min_M]", + } + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + from app.core.time_utils import advance_time + + delta = arguments.get("delta") + try: + new_time = advance_time(ctx.world.current_time, delta, ctx.world.time_schema) + except ValueError as e: + return ToolResult(ok=False, error_code="validation_error", error_message=str(e)) + ctx.world.current_time = new_time + await ctx.db.flush() + return ToolResult( + ok=True, + data={"new_time": new_time}, + message=f"Time advanced by {delta} to {new_time}", + ) + + +# --------------------------------------------------------------------------- # +# schedule_trigger +# --------------------------------------------------------------------------- # +class ScheduleTriggerTool(Tool): + name = "schedule_trigger" + category = "game" + stages = {"orchestrator_phase1", "subagent"} + description = "Schedule a deferred trigger to fire at a specific game time." + parameters_schema = { + "type": "object", + "required": ["fire_at", "event_type", "payload"], + "properties": { + "fire_at": {"type": "string", "description": "Format: [year_Y_]day_D_hour_H[_min_M]"}, + "event_type": { + "type": "string", + "enum": ["spawn_enemy", "weather_change", "quest_update", "npc_action", "custom"], + }, + "payload": {"type": "object"}, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + from app.models import DeferredTrigger + + fa = arguments.get("fire_at") + et = arguments.get("event_type") + pl = arguments.get("payload") or {} + if not fa or not et: + return ToolResult( + ok=False, error_code="validation_error", + error_message="fire_at and event_type required", + ) + trig = DeferredTrigger( + world_id=ctx.world.id, fire_at=fa, event_type=et, payload=pl + ) + ctx.db.add(trig) + await ctx.db.flush() + return ToolResult( + ok=True, + data={"trigger_id": str(trig.id)}, + message=f"Scheduled {et} at {fa}", + ) + + +# --------------------------------------------------------------------------- # +# calc +# --------------------------------------------------------------------------- # +_DICE_RE = re.compile(r"(\d*)d(\d+)") +_SAFE_RE = re.compile(r"^[0-9+\-*/%().,\s\wd]+$") + + +class CalcTool(Tool): + name = "calc" + category = "game" + stages = {"orchestrator_phase1", "subagent"} + description = ( + "Evaluate a math expression with dice support. Allowed: + - * / %, " + "min(), max(), round(), and dice notation like 2d6+3." + ) + parameters_schema = { + "type": "object", + "required": ["expression"], + "properties": { + "expression": {"type": "string", "example": "max(1, 2d6+3 - enemy.armor)"}, + "variables": { + "type": "object", + "description": "Variable substitutions, e.g. {\"enemy.armor\": 5}", + }, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + expr = arguments.get("expression", "") + variables = arguments.get("variables") or {} + # Substitute variables + trace_parts: list[str] = [] + for var, val in variables.items(): + expr = expr.replace(var, str(val)) + trace_parts.append(f"{var}={val}") + # Dice rolls + rolls: list[int] = [] + + def _roll(match: re.Match) -> str: + count = int(match.group(1) or "1") + sides = int(match.group(2)) + if sides < 1 or count < 1 or count > 100: + return "0" + results = [random.randint(1, sides) for _ in range(count)] + rolls.extend(results) + return str(sum(results)) + + expr_with_rolls = _DICE_RE.sub(_roll, expr) + if not _SAFE_RE.match(expr_with_rolls): + return ToolResult( + ok=False, + error_code="validation_error", + error_message="Expression contains disallowed characters", + ) + # Replace min/max/round with safe builtins + try: + result = eval( # noqa: S307 + expr_with_rolls, + {"__builtins__": {}}, + {"min": min, "max": max, "round": round, "abs": abs}, + ) + if isinstance(result, float) and result.is_integer(): + result = int(result) + except Exception as e: + return ToolResult( + ok=False, error_code="evaluation_error", error_message=str(e) + ) + return ToolResult( + ok=True, + data={"result": result, "rolls": rolls, "trace": "; ".join(trace_parts)}, + message=f"= {result}", + ) + + +# --------------------------------------------------------------------------- # +# random_choice +# --------------------------------------------------------------------------- # +class RandomChoiceTool(Tool): + name = "random_choice" + category = "game" + stages = {"orchestrator_phase1", "subagent"} + description = "Pick an option deterministically (seeded by world+step)." + parameters_schema = { + "type": "object", + "required": ["options"], + "properties": { + "options": {"type": "array", "items": {}, "minItems": 2}, + "weights": {"type": "array", "items": {"type": "number"}}, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + opts = arguments.get("options") or [] + weights = arguments.get("weights") + if len(opts) < 2: + return ToolResult( + ok=False, + error_code="validation_error", + error_message="Need at least 2 options", + ) + seed_str = f"{ctx.world.id}:{ctx.step_id or 'noid'}" + rng = random.Random(hash(seed_str)) + if weights: + if len(weights) != len(opts): + return ToolResult( + ok=False, error_code="validation_error", + error_message="options and weights length mismatch", + ) + pick = rng.choices(opts, weights=weights, k=1)[0] + else: + pick = rng.choice(opts) + return ToolResult(ok=True, data={"choice": pick}, message=f"Picked: {pick!r}") + + +# --------------------------------------------------------------------------- # +# rag_query / rag_add (deferred to app.core.rag) +# --------------------------------------------------------------------------- # +class RagQueryTool(Tool): + name = "rag_query" + category = "game" + stages = { + "world_builder", + "world_editor", + "orchestrator_phase1", + "subagent", + "intro_scene", + } + description = ( + "Semantic search over entities and story entries. Use when you need to recall " + "past details, NPC names, world facts. Do NOT rely on memory." + ) + parameters_schema = { + "type": "object", + "required": ["query"], + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "default": 5, "max": 20}, + "filter_type": { + "type": "string", + "enum": ["all", "entities", "story_entries"], + "default": "all", + }, + "min_score": {"type": "number", "default": 0.7}, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + from app.core.rag import rag_query + + try: + results = await rag_query( + db=ctx.db, + world_id=ctx.world.id, + query=arguments.get("query", ""), + limit=arguments.get("limit", 5), + filter_type=arguments.get("filter_type", "all"), + min_score=arguments.get("min_score", 0.0), + ) + except Exception as e: # noqa: BLE001 + _logger.warning("rag_query_tool_failed", error=str(e)) + return ToolResult( + ok=True, + data={"results": []}, + message="RAG unavailable, returning empty results", + ) + return ToolResult( + ok=True, + data={"results": results}, + message=f"Found {len(results)} matches", + ) + + +class RagAddTool(Tool): + name = "rag_add" + category = "game" + stages = { + "world_builder", + "orchestrator_phase1", + "subagent", + "intro_scene", + } + description = ( + "Persist a fact/event as a story entry and index it for semantic search. " + "Use when the player learns a persistent fact (NPC secret, lore, quest outcome)." + ) + parameters_schema = { + "type": "object", + "required": ["content", "entry_type"], + "properties": { + "content": {"type": "string"}, + "entry_type": { + "type": "string", + "enum": ["fact", "event", "relationship", "secret"], + }, + "metadata": {"type": "object"}, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + from app.core.rag import rag_add + + entry = await rag_add( + db=ctx.db, + world_id=ctx.world.id, + content=arguments.get("content", ""), + entry_type=arguments.get("entry_type", "fact"), + metadata=arguments.get("metadata"), + step_id=ctx.step_id, + ) + return ToolResult( + ok=True, + data={"id": str(entry.id), "status": entry.embedding_status}, + message=f"Added story entry ({entry.entry_type})", + ) + + +# --------------------------------------------------------------------------- # +# submit_plan / submit_step / suggest_actions — terminal tools +# --------------------------------------------------------------------------- # +class SubmitPlanTool(Tool): + name = "submit_plan" + category = "game" + stages = {"world_builder", "orchestrator_phase1"} + description = "End Phase 1. Pass the plan + summary to Phase 2 writer." + parameters_schema = { + "type": "object", + "required": ["plan", "summary"], + "properties": { + "plan": {"type": "string"}, + "summary": {"type": "array", "items": {"type": "object"}}, + "offscreen_events": {"type": "array", "items": {"type": "string"}}, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + # Signal handled by orchestrator loop — just echo back + return ToolResult( + ok=True, + data={ + "plan": arguments.get("plan", ""), + "summary": arguments.get("summary", []), + "offscreen_events": arguments.get("offscreen_events", []), + }, + message="Phase 1 complete", + ) + + +class SubmitStepTool(Tool): + name = "submit_step" + category = "game" + stages = {"orchestrator_phase2", "intro_scene"} + description = "End Phase 2. Writer returns the final narrative + time delta." + parameters_schema = { + "type": "object", + "required": ["scene_text", "delta_time"], + "properties": { + "scene_text": {"type": "string", "minLength": 100, "maxLength": 4000}, + "delta_time": {"type": "string"}, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + text = arguments.get("scene_text", "") + if len(text) < 100: + return ToolResult( + ok=False, + error_code="validation_error", + error_message=f"scene_text must be >= 100 chars (got {len(text)})", + ) + return ToolResult( + ok=True, + data={"scene_text": text, "delta_time": arguments.get("delta_time", "hours_1")}, + message="Phase 2 complete", + ) + + +class SuggestActionsTool(Tool): + name = "suggest_actions" + category = "game" + stages = {"orchestrator_phase3_suggest", "intro_scene"} + description = "Generate 1-3 next actions for the player." + parameters_schema = { + "type": "object", + "required": ["actions"], + "properties": { + "actions": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 3} + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + actions = arguments.get("actions") or [] + if not actions or len(actions) > 3: + return ToolResult( + ok=False, + error_code="validation_error", + error_message="Need 1-3 actions", + ) + return ToolResult(ok=True, data={"actions": actions}, message="Suggestions ready") + + +# --------------------------------------------------------------------------- # +# Interaction tools (used in world_builder / world_editor) +# --------------------------------------------------------------------------- # +class AskUserTool(Tool): + name = "ask_user" + category = "interaction" + stages = {"world_builder", "world_editor"} + description = "Ask the player a clarification question. Blocks until answer." + parameters_schema = { + "type": "object", + "required": ["question"], + "properties": { + "question": {"type": "string"}, + "options": {"type": "array", "items": {"type": "string"}}, + "allow_free_text": {"type": "boolean", "default": True}, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + # The orchestrator/world_builder loop must intercept this tool before execution + # and emit a clarification SSE event; here we just echo back the question. + return ToolResult( + ok=True, + data={ + "question": arguments.get("question"), + "options": arguments.get("options"), + "allow_free_text": arguments.get("allow_free_text", True), + "_blocking": True, + }, + message="Awaiting user answer", + ) + + +class ProposeChangesTool(Tool): + name = "propose_changes" + category = "interaction" + stages = {"world_editor"} + description = "Propose a diff to the player for accept/reject." + parameters_schema = { + "type": "object", + "required": ["diff"], + "properties": { + "diff": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "op": {"type": "string", "enum": ["add", "remove", "replace"]}, + "old": {}, + "new": {}, + }, + }, + }, + "comment": {"type": "string"}, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + return ToolResult( + ok=True, + data={"diff": arguments.get("diff", []), "comment": arguments.get("comment", "")}, + message="Proposed changes", + ) + + +class CommentToUserTool(Tool): + name = "comment_to_user" + category = "interaction" + stages = {"world_builder", "world_editor"} + description = "Send a text comment to the user (no answer expected)." + parameters_schema = { + "type": "object", + "required": ["text"], + "properties": {"text": {"type": "string"}}, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + return ToolResult(ok=True, data={"text": arguments.get("text", "")}, message="Comment sent") + + +# --------------------------------------------------------------------------- # +# run_subagent +# --------------------------------------------------------------------------- # +class RunSubagentTool(Tool): + name = "run_subagent" + category = "game" + stages = {"orchestrator_phase1"} + description = "Run an offscreen sub-LLM call for background events." + parameters_schema = { + "type": "object", + "required": ["task", "tools"], + "properties": { + "task": {"type": "string"}, + "tools": {"type": "array", "items": {"type": "string"}}, + "context": {"type": "object"}, + "max_iterations": {"type": "integer", "default": 5, "max": 10}, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + # Full implementation in app.engine.subagent + return ToolResult( + ok=True, + data={ + "task": arguments.get("task"), + "tools": arguments.get("tools"), + "context": arguments.get("context"), + "max_iterations": arguments.get("max_iterations", 5), + "_deferred": True, + }, + message="Subagent requested (executor handles)", + ) diff --git a/app/engine/tools/register_all.py b/app/engine/tools/register_all.py new file mode 100644 index 0000000..b3d4750 --- /dev/null +++ b/app/engine/tools/register_all.py @@ -0,0 +1,56 @@ +"""Build the default tool registry — instantiates and registers all tools.""" + +from __future__ import annotations + +from app.engine.tools.base import ToolRegistry +from app.engine.tools.game import ( + AdvanceTimeTool, + AskUserTool, + CalcTool, + CommentToUserTool, + EnvGetTool, + EnvUpdateTool, + EntityCreateTool, + EntityDeleteTool, + EntityGetTool, + EntityListTool, + EntityUpdateTool, + ProposeChangesTool, + RagAddTool, + RagQueryTool, + RandomChoiceTool, + RunSubagentTool, + ScheduleTriggerTool, + SubmitPlanTool, + SubmitStepTool, + SuggestActionsTool, + UpdatePlotRailsTool, +) +from app.engine.tools.schema_tools import ( + SchemaAddFieldTool, + SchemaAddTypeTool, + SchemaModifyFieldTool, + SchemaRemoveFieldTool, +) + + +def build_default_registry() -> ToolRegistry: + """Construct and return a ToolRegistry with all built-in tools registered.""" + reg = ToolRegistry() + # Game tools + for cls in [ + EntityCreateTool, EntityGetTool, EntityListTool, EntityUpdateTool, + EntityDeleteTool, EnvUpdateTool, EnvGetTool, UpdatePlotRailsTool, + AdvanceTimeTool, ScheduleTriggerTool, CalcTool, RandomChoiceTool, + RagQueryTool, RagAddTool, RunSubagentTool, + SubmitPlanTool, SubmitStepTool, SuggestActionsTool, + ]: + reg.register(cls()) + # Interaction tools + for cls in [AskUserTool, ProposeChangesTool, CommentToUserTool]: + reg.register(cls()) + # Schema tools + for cls in [SchemaAddTypeTool, SchemaAddFieldTool, + SchemaRemoveFieldTool, SchemaModifyFieldTool]: + reg.register(cls()) + return reg diff --git a/app/engine/tools/schema_tools.py b/app/engine/tools/schema_tools.py new file mode 100644 index 0000000..0156068 --- /dev/null +++ b/app/engine/tools/schema_tools.py @@ -0,0 +1,146 @@ +"""Schema tools for world_editor — add/modify/remove entity types and fields.""" + +from __future__ import annotations + +from typing import Any + +from app.engine.tools.base import Tool, ToolContext, ToolResult + + +def _find_schema(world_schemas: list[dict], type_name: str) -> dict | None: + for s in world_schemas: + if s.get("type") == type_name: + return s + return None + + +class SchemaAddTypeTool(Tool): + name = "schema_add_type" + category = "schema" + stages = {"world_builder", "world_editor"} + description = "Add a new entity type to world.schemas." + parameters_schema = { + "type": "object", + "required": ["type", "verbose", "plural", "properties"], + "properties": { + "type": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}, + "verbose": {"type": "string"}, + "plural": {"type": "string"}, + "properties": {"type": "array", "items": {"type": "object"}}, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + type_name = arguments.get("type") + schemas = list(ctx.world.schemas or []) + if _find_schema(schemas, type_name): + return ToolResult( + ok=False, error_code="name_conflict", + error_message=f"Type {type_name!r} already exists", + ) + schemas.append({ + "type": type_name, + "verbose": arguments.get("verbose"), + "plural": arguments.get("plural"), + "properties": arguments.get("properties") or [], + }) + ctx.world.schemas = schemas + await ctx.db.flush() + return ToolResult(ok=True, data={"type": type_name}, message=f"Type {type_name!r} added") + + +class SchemaAddFieldTool(Tool): + name = "schema_add_field" + category = "schema" + stages = {"world_builder", "world_editor"} + description = "Add a field to an existing entity type." + parameters_schema = { + "type": "object", + "required": ["entity_type", "field"], + "properties": { + "entity_type": {"type": "string"}, + "field": {"type": "object"}, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + et = arguments.get("entity_type") + field = arguments.get("field") or {} + schemas = list(ctx.world.schemas or []) + s = _find_schema(schemas, et) + if s is None: + return ToolResult(ok=False, error_code="not_found", + error_message=f"Type {et!r} not found") + props = list(s.get("properties") or []) + if any(p.get("name") == field.get("name") for p in props): + return ToolResult(ok=False, error_code="name_conflict", + error_message=f"Field {field.get('name')!r} already exists") + props.append(field) + s["properties"] = props + ctx.world.schemas = schemas + await ctx.db.flush() + return ToolResult(ok=True, data={"type": et, "field": field.get("name")}, + message=f"Field added to {et!r}") + + +class SchemaRemoveFieldTool(Tool): + name = "schema_remove_field" + category = "schema" + stages = {"world_builder", "world_editor"} + description = "Remove a field from an entity type." + parameters_schema = { + "type": "object", + "required": ["entity_type", "field_name"], + "properties": { + "entity_type": {"type": "string"}, + "field_name": {"type": "string"}, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + et = arguments.get("entity_type") + fn = arguments.get("field_name") + schemas = list(ctx.world.schemas or []) + s = _find_schema(schemas, et) + if s is None: + return ToolResult(ok=False, error_code="not_found", + error_message=f"Type {et!r} not found") + props = [p for p in (s.get("properties") or []) if p.get("name") != fn] + s["properties"] = props + ctx.world.schemas = schemas + await ctx.db.flush() + return ToolResult(ok=True, data={"removed": fn}, message=f"Field {fn!r} removed from {et!r}") + + +class SchemaModifyFieldTool(Tool): + name = "schema_modify_field" + category = "schema" + stages = {"world_builder", "world_editor"} + description = "Modify an existing field of an entity type." + parameters_schema = { + "type": "object", + "required": ["entity_type", "field_name", "changes"], + "properties": { + "entity_type": {"type": "string"}, + "field_name": {"type": "string"}, + "changes": {"type": "object"}, + }, + } + + async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: + et = arguments.get("entity_type") + fn = arguments.get("field_name") + changes = arguments.get("changes") or {} + schemas = list(ctx.world.schemas or []) + s = _find_schema(schemas, et) + if s is None: + return ToolResult(ok=False, error_code="not_found", + error_message=f"Type {et!r} not found") + for p in (s.get("properties") or []): + if p.get("name") == fn: + p.update(changes) + ctx.world.schemas = schemas + await ctx.db.flush() + return ToolResult(ok=True, data=p, message=f"Field {fn!r} modified") + return ToolResult(ok=False, error_code="not_found", + error_message=f"Field {fn!r} not found in {et!r}") diff --git a/app/engine/world_builder.py b/app/engine/world_builder.py new file mode 100644 index 0000000..11fe2e3 --- /dev/null +++ b/app/engine/world_builder.py @@ -0,0 +1,301 @@ +"""World Builder — generates a new world from a preset or form, then intro scene. + +Flow (see §9.1 of TDD): +1. Receive template (preset or form). +2. Generate schemas + environment_schema + rules + time_schema. +3. Generate initial environment (player + current_location + plot_rails). +4. Generate initial entities (locations, NPCs, items). +5. Generate intro scene + suggested actions. +6. Mark world status='ready'. +""" + +from __future__ import annotations + +import json +import uuid +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.llm import LlmClient, MockLlmClient +from app.core.logging import get_logger +from app.core.state_validator import validate_world +from app.core.time_utils import summarize_schemas +from app.engine.sse import SseEmitter +from app.engine.tools.base import ToolContext, get_registry +from app.models import World, WorldPreset +from app.prompts.registry import get_prompt + +_logger = get_logger(__name__) + + +async def run_world_builder( + *, + db: AsyncSession, + world: World, + player_name: str, + notes: str | None, + llm: LlmClient | MockLlmClient, + sse: SseEmitter, + preset: WorldPreset | None = None, +) -> None: + """Run the full world_builder flow for a draft world. + + Emits SSE events and updates the world row in place. On error, emits `error` + and returns (the world stays in status='draft'). + """ + try: + # ---- Step 1: Generate schemas / rules / time_schema / environment_schema + await sse.emit("step", {"step": "generating_schema", "message": "Generating world schema..."}) + schema_prompt = get_prompt("world_builder_schema", "en").format( + mode="preset" if preset else "form", + form_data=json.dumps({}, ensure_ascii=False), + preset_name=preset.name if preset else "", + player_name=player_name, + language=world.language, + notes=notes or "", + ) + # If we have a preset, use its schemas directly instead of calling LLM + if preset and preset.schemas: + world.schemas = preset.schemas + world.environment_schema = preset.environment_schema + world.rules = preset.rules + world.time_schema = preset.time_schema + world.environment = dict(preset.environment_initial) + else: + resp = await llm.complete( + stage="world_builder_schema", + messages=[{"role": "system", "content": schema_prompt}], + temperature=0.5, + max_tokens=4096, + world_id=world.id, + session=db, + ) + try: + content = resp["message"].get("content", "") + # Strip markdown fences if present + content = _strip_code_fence(content) + schema_data = json.loads(content) + except (json.JSONDecodeError, KeyError) as e: + await sse.error("schema_generation_failed", f"Invalid JSON from LLM: {e}") + return + world.schemas = schema_data.get("schemas", []) + world.environment_schema = schema_data.get("environment_schema", []) + world.rules = schema_data.get("rules", []) + world.time_schema = schema_data.get("time_schema", {"hours_in_day": 24, "initial_date": "day_1_hour_8"}) + world.environment = schema_data.get("environment_initial", {}) + # Ensure player name is set + env = dict(world.environment or {}) + if isinstance(env.get("player"), dict): + env["player"]["name"] = player_name + else: + env["player"] = {"name": player_name} + world.environment = env + await db.commit() + await sse.emit("world_schema_generated", { + "schemas": world.schemas, "environment_schema": world.environment_schema, + }) + + # ---- Step 2: Generate environment (skip if preset provided one) + if not preset or not preset.environment_initial: + await sse.emit("step", {"step": "generating_environment", "message": "Generating environment..."}) + env_prompt = get_prompt("world_builder_env", "en").format( + world_name=world.name, + world_description=world.description or "", + language=world.language, + rules="\n".join(f"- {r}" for r in (world.rules or [])), + schemas_summary=summarize_schemas(world.schemas or []), + environment_schema_json=json.dumps(world.environment_schema, ensure_ascii=False, indent=2), + player_name=player_name, + ) + resp = await llm.complete( + stage="world_builder_env", + messages=[{"role": "system", "content": env_prompt}], + temperature=0.6, + max_tokens=2048, + world_id=world.id, + session=db, + ) + try: + content = _strip_code_fence(resp["message"].get("content", "")) + env_data = json.loads(content) + env_data.setdefault("player", {}).setdefault("name", player_name) + world.environment = env_data + except (json.JSONDecodeError, KeyError) as e: + await sse.error("env_generation_failed", f"Invalid env JSON: {e}") + return + await db.commit() + await sse.emit("environment_generated", {"environment": world.environment}) + + # Validate world + ok, errors = validate_world({ + "name": world.name, "language": world.language, + "schemas": world.schemas, "environment_schema": world.environment_schema, + "environment": world.environment, "plot_rails": world.plot_rails, + "current_time": world.current_time, + }) + if not ok: + await sse.error("world_invalid", "World validation failed", details=errors) + return + + # ---- Step 3: Generate entities via tool-calling loop + await sse.emit("step", {"step": "generating_entities", "message": "Generating entities..."}) + await _run_tool_loop( + db=db, world=world, llm=llm, sse=sse, + stage="world_builder_entities", + system_prompt=get_prompt("world_builder_entities", "en").format( + world_name=world.name, + world_description=world.description or "", + language=world.language, + schemas_summary=summarize_schemas(world.schemas or []), + environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2), + max_substeps=12, + ), + terminal_tool="submit_plan", + max_substeps=12, + settings={}, # world_builder uses fixed defaults + ) + await db.commit() + await sse.emit("entities_generated", {"world_id": str(world.id)}) + + # ---- Step 4: Generate intro scene + await sse.emit("step", {"step": "generating_intro", "message": "Generating intro scene..."}) + from sqlalchemy import select + + from app.models import Entity + + entities = ( + await db.execute( + select(Entity).where( + Entity.world_id == world.id, Entity.deleted_at.is_(None) + ) + ) + ).scalars().all() + entities_summary = "\n".join( + f"- {e.entity_type}: {e.name}" for e in entities[:20] + ) + intro_prompt = get_prompt("intro_scene", "en").format( + world_name=world.name, + world_description=world.description or "", + language=world.language, + current_time=world.current_time, + environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2), + plot_rails_json=json.dumps(world.plot_rails, ensure_ascii=False, indent=2), + entities_summary=entities_summary, + ) + # Phase 2: scene_text + delta_time + scene_result = await _run_tool_loop( + db=db, world=world, llm=llm, sse=sse, + stage="intro_scene", + system_prompt=intro_prompt, + terminal_tool="submit_step", + max_substeps=3, + settings={}, + ) + scene_text = "" + delta_time = "hours_1" + if scene_result and scene_result.get("ok"): + scene_text = scene_result.get("data", {}).get("scene_text", "") + delta_time = scene_result.get("data", {}).get("delta_time", "hours_1") + world.intro_scene = scene_text + from app.core.time_utils import advance_time + + world.current_time = advance_time(world.current_time, delta_time, world.time_schema) + await db.commit() + await sse.emit("intro_scene_complete", { + "text": scene_text, "delta_time": delta_time, "current_time": world.current_time, + }) + + # Mark ready + world.status = "ready" + await db.commit() + await sse.done({"world_id": str(world.id), "status": "ready"}) + except Exception as e: # noqa: BLE001 + _logger.exception("world_builder_failed", world_id=str(world.id), error=str(e)) + await sse.error("internal_error", str(e)) + + +def _strip_code_fence(text: str) -> str: + """Remove ```json ... ``` fences if present.""" + s = text.strip() + if s.startswith("```"): + # Remove first line (``` or ```json) + s = s.split("\n", 1)[1] if "\n" in s else s + if s.endswith("```"): + s = s[:-3] + return s.strip() + + +async def _run_tool_loop( + *, + db: AsyncSession, + world: World, + llm: LlmClient | MockLlmClient, + sse: SseEmitter, + stage: str, + system_prompt: str, + terminal_tool: str, + max_substeps: int, + settings: dict[str, Any], +) -> dict[str, Any] | None: + """Generic tool-calling loop. Returns the result of the terminal tool call.""" + registry = get_registry() + ctx = ToolContext( + db=db, world=world, stage=stage, + sse_emitter=sse.emit, + ) + messages: list[dict[str, Any]] = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Begin {stage}."}, + ] + tools = registry.to_openai_format(stage) + last_terminal_result: dict[str, Any] | None = None + + for substep in range(max_substeps): + await sse.emit("llm_call_start", {"stage": stage, "model": getattr(llm, "_model", "mock")}) + resp = await llm.complete( + stage=stage, + messages=messages, + tools=tools, + temperature=0.7, + max_tokens=2048, + world_id=world.id, + session=db, + ) + await sse.emit("llm_call_end", { + "stage": stage, "latency_ms": resp.get("latency_ms", 0), + "tokens": (resp.get("prompt_tokens") or 0) + (resp.get("completion_tokens") or 0), + }) + msg = resp.get("message", {}) + tool_calls = msg.get("tool_calls") or [] + if not tool_calls: + # No tool calls — append assistant message and ask again + messages.append({"role": "assistant", "content": msg.get("content", "")}) + messages.append({ + "role": "user", + "content": "You must call a tool. Available terminal tool: " + terminal_tool, + }) + continue + + messages.append(msg) + for tc in tool_calls: + fn = tc.get("function", {}) + tname = fn.get("name", "") + try: + targs = json.loads(fn.get("arguments") or "{}") + except json.JSONDecodeError: + targs = {} + result = await registry.execute(tname, targs, ctx) + # Tool result as a tool message + messages.append({ + "role": "tool", + "tool_call_id": tc.get("id", ""), + "name": tname, + "content": json.dumps(result.to_dict(), ensure_ascii=False), + }) + if tname == terminal_tool: + last_terminal_result = result.to_dict() + return last_terminal_result + + # If we exhausted substeps without terminal, return None + return last_terminal_result diff --git a/app/engine/world_editor.py b/app/engine/world_editor.py new file mode 100644 index 0000000..7d607c9 --- /dev/null +++ b/app/engine/world_editor.py @@ -0,0 +1,148 @@ +"""World Editor — chat-based editing of an existing world.""" + +from __future__ import annotations + +import json +import uuid +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.llm import LlmClient, MockLlmClient +from app.core.logging import get_logger +from app.core.time_utils import summarize_schemas +from app.engine.sse import SseEmitter +from app.engine.tools.base import ToolContext, get_registry +from app.models import Entity, World +from app.prompts.registry import get_prompt + +_logger = get_logger(__name__) + + +async def run_world_editor( + *, + db: AsyncSession, + world: World, + instruction: str, + llm: LlmClient | MockLlmClient, + sse: SseEmitter, + max_iterations: int = 8, +) -> None: + """Run a world_editor iteration: instruction → propose_changes → done. + + Simplified (vs §9.2): no `ask_user` blocking — the LLM gets one shot at + producing a `propose_changes` (or applies tool calls directly if simple). + """ + try: + registry = get_registry() + ctx = ToolContext(db=db, world=world, stage="world_editor", sse_emitter=sse.emit) + + # Snapshot current entities for the prompt + entities = ( + await db.execute( + select(Entity).where( + Entity.world_id == world.id, Entity.deleted_at.is_(None) + ).limit(30) + ) + ).scalars().all() + entities_summary = "\n".join( + f"- {e.entity_type}: {e.name}" for e in entities + ) + sys_prompt = get_prompt("world_editor", "en").format( + world_name=world.name, + world_description=world.description or "", + language=world.language, + schemas_summary=summarize_schemas(world.schemas or []), + environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2), + entities_summary=entities_summary, + instruction=instruction, + ) + messages: list[dict[str, Any]] = [ + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": instruction}, + ] + tools = registry.to_openai_format("world_editor") + for _ in range(max_iterations): + resp = await llm.complete( + stage="world_editor", + messages=messages, + tools=tools, + temperature=0.5, + max_tokens=2048, + world_id=world.id, + session=db, + ) + msg = resp.get("message", {}) + tcs = msg.get("tool_calls") or [] + if not tcs: + # Done + await sse.emit("comment", {"text": msg.get("content", "")}) + break + messages.append(msg) + done = False + for tc in tcs: + fn = tc.get("function", {}) + tname = fn.get("name", "") + try: + targs = json.loads(fn.get("arguments") or "{}") + except json.JSONDecodeError: + targs = {} + if tname == "ask_user": + # Non-interactive: emit clarification and stop + await sse.emit("clarification", { + "question": targs.get("question"), + "options": targs.get("options"), + }) + await sse.done({"status": "needs_clarification"}) + return + if tname == "propose_changes": + await sse.emit("change_proposed", { + "diff": targs.get("diff", []), + "comment": targs.get("comment", ""), + }) + # Apply changes directly (simplified: auto-accept) + await _apply_diff(world, targs.get("diff", [])) + await db.commit() + await sse.emit("apply_changes", {}) + done = True + break + # Execute tool + result = await registry.execute(tname, targs, ctx) + messages.append({ + "role": "tool", + "tool_call_id": tc.get("id", ""), + "name": tname, + "content": json.dumps(result.to_dict(), ensure_ascii=False), + }) + if done: + break + await sse.done({"status": "completed"}) + except Exception as e: # noqa: BLE001 + _logger.exception("world_editor_failed", world_id=str(world.id), error=str(e)) + await sse.error("internal_error", str(e)) + + +async def _apply_diff(world: World, diff: list[dict[str, Any]]) -> None: + """Apply a propose_changes diff to the world. + + Supports paths into environment and basic field operations. + """ + from app.core.state_validator import apply_patch + + env_patch: dict[str, Any] = {} + schemas_patch: dict[str, Any] = {} + for d in diff: + path = d.get("path", "") + op = d.get("op", "replace") + new = d.get("new") + if path.startswith("environment."): + field = path[len("environment."):] + env_patch[field] = new + elif path.startswith("schemas."): + # For simplicity, replace entire schemas if any schema patch present + schemas_patch[path] = new + if env_patch: + new_env, errors = apply_patch(dict(world.environment or {}), env_patch) + if not errors: + world.environment = new_env diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f0c0c4d --- /dev/null +++ b/app/main.py @@ -0,0 +1,123 @@ +"""FastAPI app factory and lifespan (DB + Qdrant init, settings seed).""" + +from __future__ import annotations + +import os +from contextlib import asynccontextmanager +from collections.abc import AsyncIterator +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from sqlalchemy import select, text + +from app import __version__ +from app.api import admin, auth, misc, presets, sessions, worlds +from app.config import get_settings +from app.core.logging import configure_logging, get_logger +from app.core.qdrant_client import dispose_qdrant_client, init_qdrant_collections +from app.core.settings_service import ( + get_admin_setup_token, + seed_default_settings, +) +from app.db import dispose_engine, get_sessionmaker +from app.models import Setting, User + +_logger = get_logger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """Application startup / shutdown lifecycle.""" + cfg = get_settings() + configure_logging() + _logger.info("app_starting", version=__version__, debug=cfg.debug) + + # Startup ------------------------------------------------------------- + sm = get_sessionmaker() + try: + async with sm() as session: + # 1) seed settings (idempotent) + await seed_default_settings(session) + # 2) ensure admin setup token + token = await get_admin_setup_token(session) + # 3) check if any admin exists + from sqlalchemy import func + + admins_count = ( + await session.execute( + select(func.count(User.id)).where(User.is_admin.is_(True)) + ) + ).scalar_one() + if admins_count == 0: + _logger.warning( + "no_admin_yet", + setup_url=f"/register/admin?token={token}", + ) + print(f"\n=== AI-RPG Admin Setup ===") + print(f"No admin user yet. Open this URL in your browser:") + print(f" /register/admin?token={token}") + print(f"===========================\n") + else: + _logger.info("admins_present", count=admins_count) + # 4) Init Qdrant collections + from app.core.settings_service import get_setting + + dim = int(await get_setting(session, "embeddings.dimension") or 256) + try: + await init_qdrant_collections(dim) + except Exception as e: # noqa: BLE001 + _logger.warning("qdrant_init_failed", error=str(e)) + except Exception as e: # noqa: BLE001 + _logger.error("startup_failed", error=str(e)) + # Don't crash — let /api/health reflect the broken state + pass + + yield + + # Shutdown ------------------------------------------------------------ + _logger.info("app_stopping") + await dispose_qdrant_client() + await dispose_engine() + + +def create_app() -> FastAPI: + """Build and return the FastAPI application.""" + cfg = get_settings() + app = FastAPI( + title=cfg.app_name, + version=__version__, + description="AI-RPG — text RPG with an LLM Game Master.", + lifespan=lifespan, + docs_url="/api/docs", + redoc_url=None, + openapi_url="/api/openapi.json", + ) + + # CORS + app.add_middleware( + CORSMiddleware, + allow_origins=cfg.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Static asset serving (icons / uploads) + assets_dir = Path(cfg.assets_dir) + assets_dir.mkdir(parents=True, exist_ok=True) + app.mount("/static/assets", StaticFiles(directory=str(assets_dir)), name="assets") + + # Routers + app.include_router(misc.router) + app.include_router(auth.router) + app.include_router(worlds.router) + app.include_router(sessions.router) + app.include_router(presets.router) + app.include_router(admin.router) + + return app + + +app = create_app() diff --git a/app/migrations/__init__.py b/app/migrations/__init__.py new file mode 100644 index 0000000..d6aab81 --- /dev/null +++ b/app/migrations/__init__.py @@ -0,0 +1 @@ +"""Migrations package.""" diff --git a/app/migrations/init_db.py b/app/migrations/init_db.py new file mode 100644 index 0000000..4ced9ce --- /dev/null +++ b/app/migrations/init_db.py @@ -0,0 +1,14 @@ +"""Database initialization utilities — used by alembic env and CLI.""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.settings_service import seed_default_settings +from app.migrations.seed import seed_builtin_presets + + +async def init_db(session: AsyncSession) -> None: + """Seed settings + builtin presets. Idempotent.""" + await seed_default_settings(session) + await seed_builtin_presets(session) diff --git a/app/migrations/init_qdrant.py b/app/migrations/init_qdrant.py new file mode 100644 index 0000000..a04ac39 --- /dev/null +++ b/app/migrations/init_qdrant.py @@ -0,0 +1,11 @@ +"""Qdrant collection initializer — runs on application startup. + +Creates the `entities` and `story_entries` collections with payload indexes +on `world_id` (and per-collection secondary indexes). +""" + +from __future__ import annotations + +from app.core.qdrant_client import init_qdrant_collections + +__all__ = ["init_qdrant_collections"] diff --git a/app/migrations/seed.py b/app/migrations/seed.py new file mode 100644 index 0000000..38ee04c --- /dev/null +++ b/app/migrations/seed.py @@ -0,0 +1,273 @@ +"""Seed builtin world presets (fantasy + sci-fi). + +Idempotent: skips presets whose name already exists. +""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import WorldPreset + +# --------------------------------------------------------------------------- # +# Fantasy preset +# --------------------------------------------------------------------------- # +FANTASY_PRESET = { + "name": "Classic Fantasy", + "description": ( + "A high-fantasy world with taverns, dungeons, magic, and monsters. " + "Standard d20-style stats. Default setting for new players." + ), + "language": "en", + "rules": [ + "Magic requires mana; mana regenerates with sleep.", + "Combat is turn-based; stats.health is the HP pool.", + "NPCs remember their relationship to the player across sessions.", + "Death is permanent unless a resurrection scroll is used.", + ], + "time_schema": {"hours_in_day": 24, "initial_date": "day_1_hour_8"}, + "schemas": [ + { + "type": "character", + "verbose": "Character", + "plural": "characters", + "properties": [ + {"name": "name", "type": "string", "required": True}, + {"name": "description", "type": "string", "required": False}, + {"name": "stats", "type": "object", "required": True, "properties": [ + {"name": "health", "type": "integer", "required": True, "min": 0, "max": 100}, + {"name": "mana", "type": "integer", "required": False, "min": 0, "max": 100}, + {"name": "strength", "type": "integer", "required": True, "min": 1, "max": 20}, + {"name": "dexterity", "type": "integer", "required": False, "min": 1, "max": 20}, + {"name": "intelligence", "type": "integer", "required": False, "min": 1, "max": 20}, + ]}, + {"name": "inventory", "type": "array", "required": False, "items": {"type": "object"}}, + {"name": "relationship", "type": "string", "required": False}, + ], + }, + { + "type": "item", + "verbose": "Item", + "plural": "items", + "properties": [ + {"name": "name", "type": "string", "required": True}, + {"name": "description", "type": "string", "required": False}, + {"name": "qty", "type": "integer", "required": False, "min": 1, "max": 9999}, + {"name": "value", "type": "integer", "required": False, "min": 0}, + ], + }, + { + "type": "location", + "verbose": "Location", + "plural": "locations", + "properties": [ + {"name": "name", "type": "string", "required": True}, + {"name": "description", "type": "string", "required": True}, + {"name": "exits", "type": "array", "required": False, "items": {"type": "string"}}, + {"name": "is_safe", "type": "boolean", "required": False}, + ], + }, + { + "type": "faction", + "verbose": "Faction", + "plural": "factions", + "properties": [ + {"name": "name", "type": "string", "required": True}, + {"name": "description", "type": "string", "required": False}, + {"name": "alignment", "type": "string", "required": False}, + ], + }, + ], + "environment_schema": [ + { + "name": "player", "type": "object", "required": True, + "properties": [ + {"name": "name", "type": "string", "required": True}, + {"name": "stats", "type": "object", "required": True, "properties": [ + {"name": "health", "type": "integer", "required": True, "min": 0, "max": 100}, + {"name": "mana", "type": "integer", "required": False, "min": 0, "max": 100}, + {"name": "strength", "type": "integer", "required": True, "min": 1, "max": 20}, + ]}, + {"name": "inventory", "type": "array", "required": False}, + {"name": "backstory", "type": "string", "required": False}, + ], + }, + {"name": "current_location", "type": "string", "required": True}, + { + "name": "plot_rails", "type": "object", "required": True, + "properties": [ + {"name": "hooks", "type": "array", "required": True}, + {"name": "current_goals", "type": "array", "required": True}, + {"name": "completed_goals", "type": "array", "required": False}, + ], + }, + ], + "environment_initial": { + "player": { + "name": "Hero", + "stats": {"health": 100, "mana": 10, "strength": 10}, + "inventory": [], + "backstory": "A wanderer with a mysterious past.", + }, + "current_location": "The Rusty Tankard Tavern", + "plot_rails": { + "hooks": [ + "Strange travelers have been seen near the old ruins.", + "The tavern keeper is looking for someone to deliver a package.", + ], + "current_goals": ["Find lodging for the night and learn local rumors."], + "completed_goals": [], + }, + }, + "is_public": True, + "status": "ready", +} + +# --------------------------------------------------------------------------- # +# Sci-fi preset +# --------------------------------------------------------------------------- # +SCI_FI_PRESET = { + "name": "Deep Space Outpost", + "description": ( + "A sci-fi setting on a remote space station. The player is a junior officer " + "investigating strange signals from the outer rim. Resource management and " + "social deduction blend with exploration." + ), + "language": "en", + "rules": [ + "Oxygen and power are limited resources; track them via env_update.", + "The station's AI is an NPC with its own agenda.", + "Combat is lethal — avoid open conflict when possible.", + "Distress signals from other ships may be traps.", + ], + "time_schema": {"hours_in_day": 24, "initial_date": "day_1_hour_8"}, + "schemas": [ + { + "type": "character", + "verbose": "Character", + "plural": "characters", + "properties": [ + {"name": "name", "type": "string", "required": True}, + {"name": "role", "type": "string", "required": False}, + {"name": "stats", "type": "object", "required": True, "properties": [ + {"name": "health", "type": "integer", "required": True, "min": 0, "max": 100}, + {"name": "oxygen", "type": "integer", "required": True, "min": 0, "max": 100}, + {"name": "tech_skill", "type": "integer", "required": False, "min": 1, "max": 20}, + ]}, + {"name": "inventory", "type": "array", "required": False}, + {"name": "loyalty", "type": "string", "required": False}, + ], + }, + { + "type": "item", + "verbose": "Item", + "plural": "items", + "properties": [ + {"name": "name", "type": "string", "required": True}, + {"name": "description", "type": "string", "required": False}, + {"name": "qty", "type": "integer", "required": False, "min": 1, "max": 9999}, + ], + }, + { + "type": "location", + "verbose": "Location", + "plural": "locations", + "properties": [ + {"name": "name", "type": "string", "required": True}, + {"name": "description", "type": "string", "required": True}, + {"name": "exits", "type": "array", "required": False}, + {"name": "is_sealed", "type": "boolean", "required": False}, + ], + }, + { + "type": "faction", + "verbose": "Faction", + "plural": "factions", + "properties": [ + {"name": "name", "type": "string", "required": True}, + {"name": "description", "type": "string", "required": False}, + {"name": "allegiance", "type": "string", "required": False}, + ], + }, + ], + "environment_schema": [ + { + "name": "player", "type": "object", "required": True, + "properties": [ + {"name": "name", "type": "string", "required": True}, + {"name": "role", "type": "string", "required": False}, + {"name": "stats", "type": "object", "required": True, "properties": [ + {"name": "health", "type": "integer", "required": True, "min": 0, "max": 100}, + {"name": "oxygen", "type": "integer", "required": True, "min": 0, "max": 100}, + {"name": "tech_skill", "type": "integer", "required": False, "min": 1, "max": 20}, + ]}, + {"name": "inventory", "type": "array", "required": False}, + {"name": "backstory", "type": "string", "required": False}, + ], + }, + {"name": "current_location", "type": "string", "required": True}, + { + "name": "plot_rails", "type": "object", "required": True, + "properties": [ + {"name": "hooks", "type": "array", "required": True}, + {"name": "current_goals", "type": "array", "required": True}, + {"name": "completed_goals", "type": "array", "required": False}, + ], + }, + ], + "environment_initial": { + "player": { + "name": "Operative", + "role": "Junior Officer", + "stats": {"health": 100, "oxygen": 100, "tech_skill": 8}, + "inventory": [], + "backstory": "Fresh out of the academy, assigned to the Outer Rim Station.", + }, + "current_location": "Station Command Module", + "plot_rails": { + "hooks": [ + "Anomalous signal detected from sector 7G.", + "The station AI has been unusually quiet lately.", + ], + "current_goals": ["Report to the commanding officer and check the signal log."], + "completed_goals": [], + }, + }, + "is_public": True, + "status": "ready", +} + + +BUILTIN_PRESETS = [FANTASY_PRESET, SCI_FI_PRESET] + + +async def seed_builtin_presets(session: AsyncSession) -> None: + """Insert builtin presets if they don't yet exist. Owned by the first admin (or a system sentinel).""" + for preset_data in BUILTIN_PRESETS: + existing = ( + await session.execute( + select(WorldPreset).where(WorldPreset.name == preset_data["name"]) + ) + ).scalar_one_or_none() + if existing is not None: + continue + # Find any admin to own the preset, or use a sentinel UUID + from app.models import User + from sqlalchemy import func + + admin = ( + await session.execute( + select(User).where(User.is_admin.is_(True)).limit(1) + ) + ).scalar_one_or_none() + owner_id = admin.id if admin else uuid.UUID("00000000-0000-0000-0000-000000000001") + preset = WorldPreset( + owner_id=owner_id, + **preset_data, + version=1, + ) + session.add(preset) + await session.commit() diff --git a/app/migrations/versions/001_initial_schema.py b/app/migrations/versions/001_initial_schema.py new file mode 100644 index 0000000..32a97f7 --- /dev/null +++ b/app/migrations/versions/001_initial_schema.py @@ -0,0 +1,29 @@ +"""Initial schema migration — creates all tables. + +This is a hand-rolled async migration that creates all tables defined in +`app.models` via SQLAlchemy `Base.metadata.create_all`. It is the equivalent +of alembic migration 001. + +For real-world deployments the project includes an `alembic.ini` and +`alembic env.py` so that incremental migrations can be added — but for the +MVP we use this single idempotent script. +""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncEngine + +from app.db import Base +from app.models import * # noqa: F401,F403 — ensure all models are imported + + +async def create_all_tables(engine: AsyncEngine) -> None: + """Create all tables defined on Base.metadata. Idempotent.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def drop_all_tables(engine: AsyncEngine) -> None: + """Drop all tables. Used in tests.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..61092b0 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,408 @@ +"""SQLAlchemy ORM models for AI-RPG. + +All tables follow the schema defined in `docs/AI-RPG_TZ_TDD.md` §5. +UUIDs are used as primary keys throughout. Timestamps are TIMESTAMPTZ. +JSONB columns are used for flexible structured data (world config, entity data, etc.). +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import ( + Boolean, + DateTime, + Float, + ForeignKey, + Index, + Integer, + String, + Text, + func, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db import Base + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +# --------------------------------------------------------------------------- # +# Users +# --------------------------------------------------------------------------- # +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_now + ) + last_login_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + worlds: Mapped[list["World"]] = relationship(back_populates="owner") + presets: Mapped[list["WorldPreset"]] = relationship(back_populates="owner") + + __table_args__ = ( + Index("idx_users_email", "email", unique=True), + Index("idx_users_username", "username", unique=True), + ) + + +# --------------------------------------------------------------------------- # +# Settings (key-value with JSONB value) +# --------------------------------------------------------------------------- # +class Setting(Base): + __tablename__ = "settings" + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False) + value: Mapped[Any] = mapped_column(JSONB, nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_now, onupdate=_now + ) + + __table_args__ = (Index("idx_settings_key", "key", unique=True),) + + +# --------------------------------------------------------------------------- # +# World presets +# --------------------------------------------------------------------------- # +class WorldPreset(Base): + __tablename__ = "world_presets" + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + owner_id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + language: Mapped[str] = mapped_column(String(8), nullable=False, default="en") + rules: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + time_schema: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + schemas: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + environment_schema: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + environment_initial: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft") + is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_now + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_now, onupdate=_now + ) + + owner: Mapped[User] = relationship(back_populates="presets") + worlds: Mapped[list["World"]] = relationship(back_populates="preset") + + +# --------------------------------------------------------------------------- # +# Worlds +# --------------------------------------------------------------------------- # +class World(Base): + __tablename__ = "worlds" + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + owner_id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + preset_id: Mapped[uuid.UUID | None] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("world_presets.id", ondelete="SET NULL"), + nullable=True, + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + language: Mapped[str] = mapped_column(String(8), nullable=False, default="en") + rules: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + time_schema: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + schemas: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + environment_schema: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + environment: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + plot_rails: Mapped[dict] = mapped_column( + JSONB, + nullable=False, + default=lambda: {"hooks": [], "current_goals": [], "completed_goals": []}, + ) + current_time: Mapped[str] = mapped_column(String(32), nullable=False, default="day_1_hour_8") + status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft") + intro_scene: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_now + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_now, onupdate=_now + ) + last_played_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + owner: Mapped[User] = relationship(back_populates="worlds") + preset: Mapped[WorldPreset | None] = relationship(back_populates="worlds") + entities: Mapped[list["Entity"]] = relationship( + back_populates="world", cascade="all, delete-orphan" + ) + steps: Mapped[list["Step"]] = relationship( + back_populates="world", cascade="all, delete-orphan" + ) + deferred_triggers: Mapped[list["DeferredTrigger"]] = relationship( + back_populates="world", cascade="all, delete-orphan" + ) + story_entries: Mapped[list["StoryEntry"]] = relationship( + back_populates="world", cascade="all, delete-orphan" + ) + + __table_args__ = ( + Index("idx_worlds_owner_id", "owner_id"), + Index("idx_worlds_status", "status"), + Index("idx_worlds_last_played_at", "last_played_at"), + ) + + +# --------------------------------------------------------------------------- # +# Entities +# --------------------------------------------------------------------------- # +class Entity(Base): + __tablename__ = "entities" + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + world_id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False + ) + entity_type: Mapped[str] = mapped_column(String(64), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + data: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + is_in_environment: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + qdrant_point_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + embedding_status: Mapped[str] = mapped_column( + String(16), nullable=False, default="pending" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_now + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_now, onupdate=_now + ) + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + world: Mapped[World] = relationship(back_populates="entities") + + __table_args__ = ( + Index("idx_entities_world_id", "world_id"), + Index("idx_entities_world_type", "world_id", "entity_type"), + Index("idx_entities_embedding_status", "embedding_status"), + ) + + +# --------------------------------------------------------------------------- # +# Steps +# --------------------------------------------------------------------------- # +class Step(Base): + __tablename__ = "steps" + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + world_id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False + ) + sequence_number: Mapped[int] = mapped_column(Integer, nullable=False) + player_action: Mapped[str] = mapped_column(Text, nullable=False) + scene_text: Mapped[str | None] = mapped_column(Text, nullable=True) + scene_delta_time: Mapped[str | None] = mapped_column(String(32), nullable=True) + suggested_actions: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + tool_calls_summary: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + metadata_: Mapped[dict] = mapped_column( + "metadata", JSONB, nullable=False, default=dict + ) + phase1_log_id: Mapped[uuid.UUID | None] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("llm_call_logs.id", ondelete="SET NULL"), + nullable=True, + ) + phase2_log_id: Mapped[uuid.UUID | None] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("llm_call_logs.id", ondelete="SET NULL"), + nullable=True, + ) + phase3_summary_log_id: Mapped[uuid.UUID | None] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("llm_call_logs.id", ondelete="SET NULL"), + nullable=True, + ) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_now + ) + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + world: Mapped[World] = relationship(back_populates="steps") + tool_calls: Mapped[list["StepToolCall"]] = relationship( + back_populates="step", cascade="all, delete-orphan" + ) + + __table_args__ = ( + Index("idx_steps_world_seq", "world_id", "sequence_number"), + Index("idx_steps_created_at", "created_at"), + ) + + +# --------------------------------------------------------------------------- # +# Step tool calls +# --------------------------------------------------------------------------- # +class StepToolCall(Base): + __tablename__ = "step_tool_calls" + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + step_id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("steps.id", ondelete="CASCADE"), nullable=False + ) + tool_name: Mapped[str] = mapped_column(String(64), nullable=False) + arguments: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + result: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + is_success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + executed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_now + ) + + step: Mapped[Step] = relationship(back_populates="tool_calls") + + +# --------------------------------------------------------------------------- # +# Deferred triggers +# --------------------------------------------------------------------------- # +class DeferredTrigger(Base): + __tablename__ = "deferred_triggers" + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + world_id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False + ) + fire_at: Mapped[str] = mapped_column(String(32), nullable=False) + event_type: Mapped[str] = mapped_column(String(64), nullable=False) + payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + is_fired: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_now + ) + fired_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + world: Mapped[World] = relationship(back_populates="deferred_triggers") + + __table_args__ = ( + Index("idx_triggers_world_pending", "world_id", "is_fired", "fire_at"), + ) + + +# --------------------------------------------------------------------------- # +# Story entries (RAG facts) +# --------------------------------------------------------------------------- # +class StoryEntry(Base): + __tablename__ = "story_entries" + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + world_id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False + ) + content: Mapped[str] = mapped_column(Text, nullable=False) + entry_type: Mapped[str] = mapped_column(String(64), nullable=False) + qdrant_point_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + embedding_status: Mapped[str] = mapped_column( + String(16), nullable=False, default="pending" + ) + metadata_: Mapped[dict] = mapped_column( + "metadata", JSONB, nullable=False, default=dict + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_now + ) + + world: Mapped[World] = relationship(back_populates="story_entries") + + __table_args__ = ( + Index("idx_story_world_type", "world_id", "entry_type"), + Index("idx_story_status", "embedding_status"), + ) + + +# --------------------------------------------------------------------------- # +# LLM call logs +# --------------------------------------------------------------------------- # +class LlmCallLog(Base): + __tablename__ = "llm_call_logs" + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID | None] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + world_id: Mapped[uuid.UUID | None] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("worlds.id", ondelete="SET NULL"), + nullable=True, + ) + step_id: Mapped[uuid.UUID | None] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("steps.id", ondelete="SET NULL"), + nullable=True, + ) + stage: Mapped[str] = mapped_column(String(64), nullable=False) + model: Mapped[str] = mapped_column(String(128), nullable=False) + request_messages: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + request_tools: Mapped[list | None] = mapped_column(JSONB, nullable=True) + response_message: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + tool_calls: Mapped[list | None] = mapped_column(JSONB, nullable=True) + prompt_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) + completion_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) + latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) + temperature: Mapped[float | None] = mapped_column(Float, nullable=True) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="ok") + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_now + ) + + __table_args__ = ( + Index("idx_logs_world_created", "world_id", "created_at"), + Index("idx_logs_stage", "stage"), + Index("idx_logs_status", "status"), + ) diff --git a/app/prompts/__init__.py b/app/prompts/__init__.py new file mode 100644 index 0000000..9201043 --- /dev/null +++ b/app/prompts/__init__.py @@ -0,0 +1 @@ +"""Prompts package — central access via `get_prompt(stage, language)`.""" diff --git a/app/prompts/registry.py b/app/prompts/registry.py new file mode 100644 index 0000000..f9ca4b8 --- /dev/null +++ b/app/prompts/registry.py @@ -0,0 +1,49 @@ +"""Prompt registry — single entry point: `get_prompt(stage, language)`. + +Per §10.1 of the TDD, all LLM prompts are stored in English (the "ru" key is +legacy and not used for new development). The narrative output language is +controlled by passing `{language}` into the prompt at format time. +""" + +from __future__ import annotations + +from app.prompts.stages import ( + intro_scene, + orchestrator_phase1, + orchestrator_phase2, + orchestrator_phase3_suggest, + orchestrator_phase3_summary, + subagent, + summary, + world_builder_entities, + world_builder_env, + world_builder_schema, + world_editor, +) + +# Map stage name -> module +_STAGE_MODULES = { + "world_builder_schema": world_builder_schema, + "world_builder_env": world_builder_env, + "world_builder_entities": world_builder_entities, + "world_editor": world_editor, + "orchestrator_phase1": orchestrator_phase1, + "orchestrator_phase2": orchestrator_phase2, + "orchestrator_phase3_summary": orchestrator_phase3_summary, + "orchestrator_phase3_suggest": orchestrator_phase3_suggest, + "intro_scene": intro_scene, + "subagent": subagent, + "summary": summary, +} + + +def get_prompt(stage: str, language: str = "en") -> str: + """Return the prompt template string for the given stage and language. + + Falls back to English if the requested language is not available. + """ + mod = _STAGE_MODULES.get(stage) + if mod is None: + raise KeyError(f"Unknown prompt stage: {stage!r}") + prompts: dict[str, str] = getattr(mod, "PROMPTS", {}) + return prompts.get(language, prompts.get("en", "")) diff --git a/app/prompts/stages/__init__.py b/app/prompts/stages/__init__.py new file mode 100644 index 0000000..5df4ab0 --- /dev/null +++ b/app/prompts/stages/__init__.py @@ -0,0 +1 @@ +"""Stages package — each module exports PROMPTS = {"en": "...", "ru": "..."}.""" diff --git a/app/prompts/stages/intro_scene.py b/app/prompts/stages/intro_scene.py new file mode 100644 index 0000000..882d935 --- /dev/null +++ b/app/prompts/stages/intro_scene.py @@ -0,0 +1,33 @@ +"""System prompt for `intro_scene` — generates the opening scene of a new world.""" + +PROMPTS = { + "en": """You are the Intro Scene writer for a text RPG. + +Write the opening scene the player will read when they start a new game. The scene +must: +- Establish the setting (use current_location from environment) +- Introduce the player character by name +- Set up the first plot hook +- End with 1-3 concrete suggested actions + +# World +{world_name} — {world_description} +Language: {language} (write the scene in this language) +Current time: {current_time} + +# Environment +{environment_json} + +# Plot rails +{plot_rails_json} + +# Entities (for reference) +{entities_summary} + +# Hard rules +- Call `submit_step` exactly once with {scene_text, delta_time}. +- scene_text length: 300-2000 characters. +- Write in second person ("You wake up in..."). +- After submit_step, call `suggest_actions` with 1-3 short actions in {language}. +""", +} diff --git a/app/prompts/stages/orchestrator_phase1.py b/app/prompts/stages/orchestrator_phase1.py new file mode 100644 index 0000000..2812324 --- /dev/null +++ b/app/prompts/stages/orchestrator_phase1.py @@ -0,0 +1,47 @@ +"""System prompt for `orchestrator_phase1` — planner + executor.""" + +PROMPTS = { + "en": """You are the Game Master (GM) of a text RPG in the world "{world_name}". + +# Your responsibilities +1. Evaluate the player's action and decide what happened mechanically. +2. Call tools for ANY state change in the world. +3. Do NOT write narrative prose — the writer will do that in Phase 2. +4. End Phase 1 by calling submit_plan with a plan and action summary. + +# World rules +{rules} + +# Entity schemas +{schemas_summary} + +# Current environment +{environment_json} + +# Plot rails +{plot_rails_json} + +# Current time +{current_time} + +# Recent history (most recent first) +{recent_history} + +# Available tools +You can call: entity_create, entity_get, entity_list, entity_update, entity_delete, +env_update, env_get, rag_query, rag_add, schedule_trigger, advance_time, calc, random_choice, +run_subagent, update_plot_rails, submit_plan. + +# Hard rules +- ANY state change goes through a tool call. Do NOT write "you took damage" in prose. +- After each tool call you receive a tool_result. Check ok=true. +- If ok=false — fix the arguments and try again. +- Use calc for dice rolls and arithmetic. Do NOT compute in your head. +- Use rag_query when you need to recall facts about NPCs, locations, or past events. +- After max {max_substeps} tool calls you MUST call submit_plan. +- The narrative language is {language} — but keep all your reasoning in English. + +# Player's action +{player_action} +""", +} diff --git a/app/prompts/stages/orchestrator_phase2.py b/app/prompts/stages/orchestrator_phase2.py new file mode 100644 index 0000000..ded1581 --- /dev/null +++ b/app/prompts/stages/orchestrator_phase2.py @@ -0,0 +1,35 @@ +"""System prompt for `orchestrator_phase2` — writer.""" + +PROMPTS = { + "en": """You are the Writer for a text RPG iteration. + +Your job: produce the narrative scene text that the player will read, based on +the plan and tool-call summary from Phase 1. + +# World +{world_name} — {world_description} +Language: {language} (write the scene in this language) +Current time: {current_time} + +# Player's action +{player_action} + +# Plan from Phase 1 +{plan} + +# Tool-call summary (what mechanically happened) +{summary_json} + +# Environment snapshot +{environment_json} + +# Hard rules +- Call `submit_step` exactly once with {scene_text, delta_time}. +- scene_text length: 200-2000 characters. +- Write in second person ("You enter the tavern..."). +- Show, don't tell — describe sensory details. +- Do NOT reference tools, schemas, or game mechanics in the narrative. +- The narrative must be in {language}. +- delta_time format: `[year_Y][days_D][hours_H][min_M]` (e.g. `hours_2_min_30`). +""", +} diff --git a/app/prompts/stages/orchestrator_phase3_suggest.py b/app/prompts/stages/orchestrator_phase3_suggest.py new file mode 100644 index 0000000..c293071 --- /dev/null +++ b/app/prompts/stages/orchestrator_phase3_suggest.py @@ -0,0 +1,23 @@ +"""System prompt for `orchestrator_phase3_suggest` — generate next-action suggestions.""" + +PROMPTS = { + "en": """You are the Suggester for a text RPG. + +Based on the latest scene, propose 1-3 short actions the player might take next. +Each action should be: +- 2-10 words +- In the game's language ({language}) +- Concrete enough to act on (not "do something") +- Varied (don't suggest 3 similar actions) + +# Latest scene +{scene_text} + +# Current goals +{current_goals} + +# Hard rules +- Call `suggest_actions` exactly once with 1-3 short action strings. +- Do not include numbering or punctuation at the start. +""", +} diff --git a/app/prompts/stages/orchestrator_phase3_summary.py b/app/prompts/stages/orchestrator_phase3_summary.py new file mode 100644 index 0000000..a5b12d5 --- /dev/null +++ b/app/prompts/stages/orchestrator_phase3_summary.py @@ -0,0 +1,18 @@ +"""System prompt for `orchestrator_phase3_summary` — compresses old messages.""" + +PROMPTS = { + "en": """You are the Summarizer for a long-running text RPG. + +Your task: produce a concise summary of the following game history. The summary +will replace these messages in the GM's context window, so it must preserve: +- Key plot developments +- Important NPC names and relationships +- Player's current goals and recent accomplishments +- Any unresolved threats or promises + +Keep the summary under 500 words. Write in English (regardless of the game's language). + +# Messages to summarize +{messages_json} +""", +} diff --git a/app/prompts/stages/subagent.py b/app/prompts/stages/subagent.py new file mode 100644 index 0000000..019e0c3 --- /dev/null +++ b/app/prompts/stages/subagent.py @@ -0,0 +1,27 @@ +"""System prompt for `subagent` — offscreen background events.""" + +PROMPTS = { + "en": """You are a Subagent handling an offscreen event in a text RPG. + +You operate behind the scenes — the player does not see your direct output, only +the consequences (state changes) and a short summary that will be appended to the +scene. + +# Your task +{task} + +# Context +{context_json} + +# Available tools +You can call: {allowed_tools} + +# Hard rules +- Make at most {max_iterations} tool calls. +- After your work, call `submit_plan` with: + - plan: a 1-sentence description of what happened offscreen + - summary: list of tool calls and their outcomes +- Do NOT call submit_step or suggest_actions. +- All reasoning in English. The plan text may be in {language}. +""", +} diff --git a/app/prompts/stages/summary.py b/app/prompts/stages/summary.py new file mode 100644 index 0000000..a4110ec --- /dev/null +++ b/app/prompts/stages/summary.py @@ -0,0 +1,5 @@ +"""System prompt for `summary` — alias for orchestrator_phase3_summary.""" + +from app.prompts.stages.orchestrator_phase3_summary import PROMPTS as _SRC + +PROMPTS = _SRC diff --git a/app/prompts/stages/world_builder_entities.py b/app/prompts/stages/world_builder_entities.py new file mode 100644 index 0000000..4f2497a --- /dev/null +++ b/app/prompts/stages/world_builder_entities.py @@ -0,0 +1,34 @@ +"""System prompt for stage `world_builder_entities` — generates the starting entities.""" + +PROMPTS = { + "en": """You are the World Builder for an AI-driven text RPG. + +Your task: create the initial set of entities for a new world. You have access +to the `entity_create` tool — call it for each entity. When done, call `submit_plan` +with a short summary. + +Guidelines: +- Create 4-8 entities: 1-2 starting locations, 1-2 NPCs (characters), 1-2 items + the player can find, optionally 1 faction. +- Names must be unique within each entity_type. +- Each entity's `data` must conform to its schema. +- For NPCs, give them a personality and a secret the player could discover. +- The first location must match `current_location` in the environment. +- DO NOT modify the environment — that's a separate step. +- After the last entity_create, call submit_plan with a 1-sentence summary. + +# World context +World: {world_name} ({world_description}) +Language: {language} +Schemas: +{schemas_summary} + +Current environment: +{environment_json} + +# Hard rules +- Use only the `entity_create` and `submit_plan` tools. +- Call submit_plan exactly once at the end. +- After max {max_substeps} tool calls you MUST call submit_plan. +""", +} diff --git a/app/prompts/stages/world_builder_env.py b/app/prompts/stages/world_builder_env.py new file mode 100644 index 0000000..50ad0a0 --- /dev/null +++ b/app/prompts/stages/world_builder_env.py @@ -0,0 +1,36 @@ +"""System prompt for stage `world_builder_env` — generates the initial environment.""" + +PROMPTS = { + "en": """You are the World Builder for an AI-driven text RPG. + +Your task: produce the initial `environment` JSON for a world whose schema has +already been generated. + +The environment must include: +- "player": a character object matching the `character` schema. The player's name is + `{player_name}`. Give them starting stats (health=100, mana=10, strength=10), + an empty inventory, and a short backstory (1-2 sentences). +- "current_location": a string naming the starting location (it must match the + name of one of the locations generated in the next step — for now just pick a + thematic starting place like "Tavern" or "Camp"). +- "plot_rails": {{"hooks": [<2 short story hooks>], "current_goals": [<1 starting goal>], + "completed_goals": []}} +- Any other fields declared in environment_schema. + +# World context +World name: {world_name} +World description: {world_description} +Language: {language} +Rules: +{rules} + +Schemas: +{schemas_summary} + +Environment schema: +{environment_schema_json} + +# Output +Return ONLY a JSON object. No commentary. The output must conform to environment_schema. +""", +} diff --git a/app/prompts/stages/world_builder_schema.py b/app/prompts/stages/world_builder_schema.py new file mode 100644 index 0000000..b906b0e --- /dev/null +++ b/app/prompts/stages/world_builder_schema.py @@ -0,0 +1,48 @@ +"""System prompt for stage `world_builder_schema` — generates the world's schemas.""" + +PROMPTS = { + "en": """You are the World Builder for an AI-driven text RPG. + +Your task: produce the JSON schema for a new world based on the player's request. + +Output a JSON object with keys: +- "name": short world name +- "description": 2-3 sentence world premise +- "language": ISO code (e.g. "en", "ru") — must match the player's requested language +- "rules": array of short rule strings the GM must follow +- "time_schema": {{"hours_in_day": 24, "initial_date": "day_1_hour_8"}} +- "schemas": array of entity-type definitions, each shaped as + {{"type": "character", "verbose": "Character", "plural": "characters", + "properties": [ + {{"name": "name", "type": "string", "required": true}}, + {{"name": "stats", "type": "object", "required": true, + "properties": [ + {{"name": "health", "type": "integer", "required": true, "min": 0, "max": 100}}, + {{"name": "mana", "type": "integer", "required": false, "min": 0, "max": 100}}, + {{"name": "strength","type": "integer", "required": true, "min": 1, "max": 20}} + ]}} + ]}} + Include at minimum: character (with stats.health, stats.mana, stats.strength, + inventory array of items), item, location, faction. +- "environment_schema": array of top-level environment fields + (e.g. player:object, current_location:string, plot_rails:object) +- "environment_initial": initial environment JSON (with player empty, current_location empty, + plot_rails with empty arrays) + +# Player request +Mode: {mode} +Form data: {form_data} +Preset name: {preset_name} +Player name: {player_name} +Language: {language} +Notes: {notes} + +# Rules for output +- Return ONLY a JSON object. No commentary. +- Keep schemas small (3-6 fields per type). +- "stats.health" must be integer with min=0 max=100. +- Always include `player` (character) and `current_location` (string) in environment_schema. +- The world is for a 7B-parameter LLM — keep schemas readable. +""", + "ru": "", # legacy — English is the source of truth per §10.1 +} diff --git a/app/prompts/stages/world_editor.py b/app/prompts/stages/world_editor.py new file mode 100644 index 0000000..524edfc --- /dev/null +++ b/app/prompts/stages/world_editor.py @@ -0,0 +1,34 @@ +"""System prompt for stage `world_editor` — chat-based world editing.""" + +PROMPTS = { + "en": """You are the World Editor for an AI-driven text RPG. + +The player has opened their world for editing and given you an instruction. +You can: +- Ask clarifying questions via `ask_user` (only if the instruction is genuinely ambiguous). +- Make changes via `entity_create`, `entity_update`, `env_update`, `schema_*` tools. +- Propose a batch of changes via `propose_changes` (the player will accept/reject). +- Comment on what you're doing via `comment_to_user`. + +# World context +World: {world_name} ({world_description}) +Language: {language} +Schemas: +{schemas_summary} + +Current environment: +{environment_json} + +Current entities (summary): +{entities_summary} + +# Player instruction +{instruction} + +# Hard rules +- Always confirm large changes with `propose_changes` before applying them. +- Use `ask_user` sparingly — at most once per instruction. +- Keep comments short. +- Do NOT call `submit_plan` or `submit_step` — those are for the orchestrator. +""", +} diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..23ca881 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,286 @@ +"""Pydantic schemas (request/response) for the API layer. + +These are NOT the same as the world's JSON-schema — see `app/core/state_validator` +for world-schema validation. Pydantic here only handles HTTP boundary validation. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, EmailStr, Field, field_validator + + +# --------------------------------------------------------------------------- # +# Auth +# --------------------------------------------------------------------------- # +class RegisterRequest(BaseModel): + email: EmailStr + username: str = Field(min_length=3, max_length=64, pattern=r"^[a-zA-Z0-9_]+$") + password: str = Field(min_length=8, max_length=128) + password_confirm: str = Field(min_length=8, max_length=128) + + @field_validator("password_confirm") + @classmethod + def _match(cls, v, info): + if "password" in info.data and v != info.data["password"]: + raise ValueError("password and password_confirm do not match") + return v + + +class AdminRegisterRequest(BaseModel): + token: str + email: EmailStr + username: str = Field(min_length=3, max_length=64, pattern=r"^[a-zA-Z0-9_]+$") + password: str = Field(min_length=8, max_length=128) + password_confirm: str = Field(min_length=8, max_length=128) + + @field_validator("password_confirm") + @classmethod + def _match(cls, v, info): + if "password" in info.data and v != info.data["password"]: + raise ValueError("password and password_confirm do not match") + return v + + +class LoginRequest(BaseModel): + login: str = Field(min_length=1, max_length=255) # email OR username + password: str = Field(min_length=1, max_length=128) + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int = 60 * 24 + user: "UserPublic" + + +class UserPublic(BaseModel): + id: uuid.UUID + email: EmailStr + username: str + is_admin: bool + is_active: bool + created_at: datetime + last_login_at: datetime | None = None + + model_config = {"from_attributes": True} + + +# --------------------------------------------------------------------------- # +# Worlds +# --------------------------------------------------------------------------- # +class WorldSummary(BaseModel): + id: uuid.UUID + name: str + description: str | None + language: str + status: str + last_played_at: datetime | None + current_time: str + created_at: datetime + preview_player_name: str | None = None + + model_config = {"from_attributes": True} + + +class WorldFull(BaseModel): + id: uuid.UUID + owner_id: uuid.UUID + preset_id: uuid.UUID | None + name: str + description: str | None + language: str + rules: list + time_schema: dict + schemas: list + environment_schema: list + environment: dict + plot_rails: dict + current_time: str + status: str + intro_scene: str | None + created_at: datetime + updated_at: datetime + last_played_at: datetime | None + + model_config = {"from_attributes": True} + + +class WorldCreateRequest(BaseModel): + mode: Literal["preset", "form"] + preset_id: uuid.UUID | None = None + form_data: dict | None = None + name: str = Field(min_length=1, max_length=255) + language: str = Field(min_length=2, max_length=8, default="en") + player_name: str = Field(min_length=1, max_length=128) + notes: str | None = None + + +class WorldPatchRequest(BaseModel): + name: str | None = None + description: str | None = None + rules: list | None = None + schemas: list | None = None + environment_schema: list | None = None + environment: dict | None = None + plot_rails: dict | None = None + time_schema: dict | None = None + current_time: str | None = None + intro_scene: str | None = None + status: str | None = None + updated_at: datetime | None = None # for optimistic locking + + +class WorldEditRequest(BaseModel): + instruction: str = Field(min_length=1, max_length=4000) + + +# --------------------------------------------------------------------------- # +# Sessions +# --------------------------------------------------------------------------- # +class IterateRequest(BaseModel): + action: str = Field(min_length=1, max_length=4000) + action_source: Literal["custom", "suggested"] = "custom" + + +class AnswerRequest(BaseModel): + text: str = Field(min_length=1, max_length=4000) + + +class SessionState(BaseModel): + world: dict + environment: dict + recent_steps: list[dict] + next_actions: list[str] + + +# --------------------------------------------------------------------------- # +# Presets +# --------------------------------------------------------------------------- # +class PresetSummary(BaseModel): + id: uuid.UUID + name: str + description: str | None + language: str + is_public: bool + status: str + version: int + created_at: datetime + + model_config = {"from_attributes": True} + + +class PresetFull(BaseModel): + id: uuid.UUID + owner_id: uuid.UUID + name: str + description: str | None + language: str + rules: list + time_schema: dict + schemas: list + environment_schema: list + environment_initial: dict + status: str + is_public: bool + version: int + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class PresetCreateRequest(BaseModel): + name: str = Field(min_length=1, max_length=255) + description: str | None = None + language: str = "en" + rules: list = Field(default_factory=list) + time_schema: dict = Field(default_factory=lambda: {"hours_in_day": 24, "initial_date": "day_1_hour_8"}) + schemas: list = Field(default_factory=list) + environment_schema: list = Field(default_factory=list) + environment_initial: dict = Field(default_factory=dict) + is_public: bool = False + + +# --------------------------------------------------------------------------- # +# Admin +# --------------------------------------------------------------------------- # +class SettingsPatchRequest(BaseModel): + """A flat dict of {setting_key: value} to upsert.""" + + settings: dict[str, Any] + + +class LlmLogOut(BaseModel): + id: uuid.UUID + stage: str + model: str + status: str + latency_ms: int | None + prompt_tokens: int | None + completion_tokens: int | None + error_message: str | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class LlmLogDetail(BaseModel): + id: uuid.UUID + user_id: uuid.UUID | None + world_id: uuid.UUID | None + step_id: uuid.UUID | None + stage: str + model: str + request_messages: list + request_tools: list | None + response_message: dict + tool_calls: list | None + prompt_tokens: int | None + completion_tokens: int | None + latency_ms: int | None + temperature: float | None + status: str + error_message: str | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class TestLlmRequest(BaseModel): + api_url: str | None = None + api_key: str | None = None + model: str | None = None + + +class TestEmbeddingsRequest(BaseModel): + api_url: str | None = None + api_key: str | None = None + model: str | None = None + provider: str | None = None + + +# --------------------------------------------------------------------------- # +# Misc +# --------------------------------------------------------------------------- # +class HealthResponse(BaseModel): + status: str + db: bool + qdrant: bool + llm: bool + embeddings: bool + version: str + + +class ErrorOut(BaseModel): + error: dict[str, Any] + + +# --------------------------------------------------------------------------- # +# Forward refs +# --------------------------------------------------------------------------- # +TokenResponse.model_rebuild() diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index 9fccbc4..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -FROM python:3.11-slim - -ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 - -WORKDIR /app - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - curl \ - && rm -rf /var/lib/apt/lists/* - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -EXPOSE 8000 - -# Default: run uvicorn with hot reload for dev. -# We pass --log-level explicitly from $LOG_LEVEL (lowercased) so uvicorn's own -# loggers (uvicorn, uvicorn.access) start at the right level from the very -# first request — without this, they stay at INFO until our lifespan runs. -# uvicorn only accepts lowercase values ('critical'|'error'|'warning'|'info'|'debug'|'trace'), -# so we pipe through `tr` to lowercase. Shell form (not exec form) for ${LOG_LEVEL} -# interpolation. -CMD sh -c 'LVL="${LOG_LEVEL:-info}"; exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --log-level "$(printf "%s" "$LVL" | tr "A-Z" "a-z")"' diff --git a/backend/app/__init__.py b/backend/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py deleted file mode 100644 index 75c7d58..0000000 --- a/backend/app/api/admin.py +++ /dev/null @@ -1,437 +0,0 @@ -"""Admin panel routes: settings, LLM logs, users.""" -from __future__ import annotations - -from typing import Any, Dict, List -from uuid import UUID - -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.post("/llm/test") -async def test_llm_endpoint( - payload: Dict[str, Any] = Body(default={}), - db: AsyncSession = Depends(get_db_dep), - _: User = Depends(require_admin), -): - """Probe the currently configured LLM endpoint from inside the backend container. - - Accepts an optional `overrides` dict with llm.* keys (e.g. to test a new - endpoint before saving). Returns: ok, base_url, model, http_status, - latency_ms, response_preview (or error + error_type). - - This is the diagnostic tool to use when the LLM call fails with - `ConnectError: All connection attempts failed` — it tells you whether - the backend container can actually reach the LLM URL. - """ - import time - import httpx - import socket - - settings_map = await get_all_settings(db) - overrides = (payload or {}).get("overrides") or {} - for k, v in overrides.items(): - if k in EDITABLE_SETTING_KEYS: - settings_map[k] = v - - base_url = str(settings_map.get("llm.base_url", "")).rstrip("/") - model = str(settings_map.get("llm.model", "local-model")) - api_key = str(settings_map.get("llm.api_key", "dummy")) - timeout_s = float(settings_map.get("llm.request_timeout", 30) or 30) - - result: Dict[str, Any] = { - "base_url": base_url, - "model": model, - "ok": False, - } - - # === Stage 1: DNS / TCP connect (without TLS) === - try: - from urllib.parse import urlparse - parsed = urlparse(base_url) - host = parsed.hostname or "" - port = parsed.port or (443 if parsed.scheme == "https" else 80) - if not host: - result["error"] = "invalid_base_url: no host" - result["error_type"] = "ConfigError" - return result - # Try to resolve + connect TCP - addrs = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) - result["dns_resolved"] = True - result["resolved_addrs"] = [a[4][0] for a in addrs[:3]] - # Try to actually open a TCP connection - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(5.0) - try: - sock.connect((host, port)) - result["tcp_connect_ok"] = True - finally: - sock.close() - except socket.gaierror as e: - result["dns_resolved"] = False - result["error"] = f"DNS resolution failed for {host}: {e}" - result["error_type"] = "DNSError" - return result - except (socket.timeout, ConnectionRefusedError, OSError) as e: - result["tcp_connect_ok"] = False - result["error"] = f"TCP connect to {host}:{port} failed: {type(e).__name__}: {e}" - result["error_type"] = type(e).__name__ - return result - - # === Stage 2: HTTP request to /v1/models (lightweight probe) === - headers = {"Content-Type": "application/json"} - if api_key and api_key != "dummy": - headers["Authorization"] = f"Bearer {api_key}" - - started = time.monotonic() - try: - async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=timeout_s, write=10.0, pool=5.0)) as client: - # First try /models (lightweight, exists on every OpenAI-compatible server) - models_url = f"{base_url}/models" - try: - resp = await client.get(models_url, headers=headers) - result["models_endpoint_status"] = resp.status_code - if resp.status_code == 200: - data = resp.json() - model_ids = [] - if isinstance(data, dict) and isinstance(data.get("data"), list): - model_ids = [m.get("id", "?") for m in data["data"][:10]] - result["available_models"] = model_ids - except Exception as e: - result["models_endpoint_error"] = f"{type(e).__name__}: {e}" - - # Now try the actual chat completions endpoint with a minimal payload - chat_url = f"{base_url}/chat/completions" - chat_payload = { - "model": model, - "messages": [{"role": "user", "content": "Reply with the single word: ok"}], - "max_tokens": 10, - "temperature": 0.1, - "stream": False, - } - resp = await client.post(chat_url, json=chat_payload, headers=headers) - result["chat_endpoint_status"] = resp.status_code - result["latency_ms"] = int((time.monotonic() - started) * 1000) - if resp.status_code >= 400: - result["error"] = f"HTTP {resp.status_code}: {resp.text[:500]}" - result["error_type"] = "HTTPError" - return result - data = resp.json() - choice = (data.get("choices") or [{}])[0] - msg = choice.get("message", {}) - result["ok"] = True - result["response_preview"] = (msg.get("content") or "")[:200] - result["usage"] = data.get("usage", {}) - return result - except httpx.ConnectError as e: - cause = getattr(e, "__cause__", None) or getattr(e, "__context__", None) - result["error"] = f"ConnectError: {e}" - if cause: - result["error"] += f" (cause: {cause})" - result["error_type"] = "ConnectError" - return result - except Exception as e: - result["error"] = f"{type(e).__name__}: {e}" - result["error_type"] = type(e).__name__ - return result - - -@router.post("/llm/test-tools") -async def test_llm_tools_endpoint( - payload: Dict[str, Any] = Body(default={}), - db: AsyncSession = Depends(get_db_dep), - _: User = Depends(require_admin), -): - """Probe whether the configured LLM endpoint supports OpenAI-style tool calls. - - Sends a minimal chat completion request WITH a `tools` array containing one - simple function (`get_time`). Returns: - - ok: bool — did the model produce ANY well-formed response? - - tool_calls_returned: bool — did the model emit at least one tool_call? - - tool_call_name: str|null — the function name the model called (if any) - - tool_call_args: dict|null — the parsed arguments (if any) - - text: str — the model's text response (if any) - - http_status: int — HTTP status of the chat-completions call - - latency_ms: int - - raw_tool_calls: list — the raw tool_calls array from the response - - error: str|null — error message if the request failed - - error_type: str|null - - Use this to verify the model actually supports function-calling before - relying on it for world-builder / orchestrator / step-writer flows. - """ - import time - import httpx - import json as _json - - settings_map = await get_all_settings(db) - overrides = (payload or {}).get("overrides") or {} - for k, v in overrides.items(): - if k in EDITABLE_SETTING_KEYS: - settings_map[k] = v - - base_url = str(settings_map.get("llm.base_url", "")).rstrip("/") - model = str(settings_map.get("llm.model", "local-model")) - api_key = str(settings_map.get("llm.api_key", "dummy")) - timeout_s = float(settings_map.get("llm.request_timeout", 30) or 30) - - result: Dict[str, Any] = { - "base_url": base_url, - "model": model, - "ok": False, - "tool_calls_returned": False, - } - - headers = {"Content-Type": "application/json"} - if api_key and api_key != "dummy": - headers["Authorization"] = f"Bearer {api_key}" - - # Minimal tool definition — the model should call this. - tools = [ - { - "type": "function", - "function": { - "name": "get_current_time", - "description": "Returns the current time. Call this when the user asks for the time.", - "parameters": { - "type": "object", - "properties": { - "timezone": { - "type": "string", - "description": "Optional timezone, e.g. 'UTC' or 'Europe/Moscow'", - }, - }, - "required": [], - }, - }, - } - ] - - chat_url = f"{base_url}/chat/completions" - chat_payload = { - "model": model, - "messages": [ - {"role": "system", "content": "You are a helpful assistant. When the user asks for the time, you MUST call the get_current_time tool."}, - {"role": "user", "content": "What time is it now? Use the get_current_time tool to find out."}, - ], - "tools": tools, - "tool_choice": "auto", - "max_tokens": 200, - "temperature": 0.0, - "stream": False, - } - - started = time.monotonic() - try: - async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=timeout_s, write=10.0, pool=5.0)) as client: - resp = await client.post(chat_url, json=chat_payload, headers=headers) - result["http_status"] = resp.status_code - result["latency_ms"] = int((time.monotonic() - started) * 1000) - if resp.status_code >= 400: - result["error"] = f"HTTP {resp.status_code}: {resp.text[:500]}" - result["error_type"] = "HTTPError" - return result - data = resp.json() - except httpx.ConnectError as e: - cause = getattr(e, "__cause__", None) or getattr(e, "__context__", None) - result["error"] = f"ConnectError: {e}" - if cause: - result["error"] += f" (cause: {cause})" - result["error_type"] = "ConnectError" - return result - except Exception as e: - result["error"] = f"{type(e).__name__}: {e}" - result["error_type"] = type(e).__name__ - return result - - try: - choice = (data.get("choices") or [{}])[0] - msg = choice.get("message", {}) - text = msg.get("content") or "" - tool_calls = msg.get("tool_calls") or [] - result["text"] = text[:500] - result["raw_tool_calls"] = tool_calls - if tool_calls: - result["tool_calls_returned"] = True - first = tool_calls[0] - fn = first.get("function", {}) if isinstance(first, dict) else {} - result["tool_call_name"] = fn.get("name") - args_str = fn.get("arguments", "{}") - try: - result["tool_call_args"] = _json.loads(args_str) if args_str else {} - except _json.JSONDecodeError: - result["tool_call_args"] = {"_raw": args_str} - result["ok"] = True - result["usage"] = data.get("usage", {}) - except Exception as e: - result["error"] = f"response_parse_failed: {type(e).__name__}: {e}" - result["error_type"] = type(e).__name__ - return result - - return result - - -@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 - ] - - -@router.post("/users/{user_id}/set-active") -async def set_user_active( - user_id: UUID, - payload: Dict[str, Any] = Body(default={}), - db: AsyncSession = Depends(get_db_dep), - admin: User = Depends(require_admin), -): - """Activate or ban a user. Banned users cannot log in (see auth.login). - - Body: `{"is_active": true|false}`. Admins cannot ban themselves. - """ - is_active = bool(payload.get("is_active")) - result = await db.execute(select(User).where(User.id == user_id)) - user = result.scalars().first() - if not user: - raise HTTPException(status_code=404, detail="user_not_found") - if user.id == admin.id and not is_active: - raise HTTPException(status_code=400, detail="cannot_ban_self") - user.is_active = is_active - await db.commit() - return { - "id": str(user.id), - "email": user.email, - "username": user.username, - "is_admin": user.is_admin, - "is_active": user.is_active, - } diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py deleted file mode 100644 index aa31e51..0000000 --- a/backend/app/api/auth.py +++ /dev/null @@ -1,89 +0,0 @@ -"""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)): - # Accept either email or username in the `login` field. - login_value = (payload.login or "").strip() - if not login_value: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="login_required") - result = await db.execute( - select(User).where((User.email == login_value) | (User.username == login_value)) - ) - user = result.scalars().first() - if not user or not verify_password(payload.password, user.hashed_password): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_credentials") - 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)) diff --git a/backend/app/api/misc.py b/backend/app/api/misc.py deleted file mode 100644 index 3064cf5..0000000 --- a/backend/app/api/misc.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Glossary + Triggers + public UI settings routes.""" -from __future__ import annotations - -from typing import Any, Dict, 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, Setting, User, World -from app.schemas import GlossaryEntryOut, TriggerOut - -router = APIRouter(prefix="/api", tags=["misc"]) - - -# Public, unauthenticated UI settings (logo URL etc.) — used by the frontend -# on the login/register/home pages BEFORE the user is authenticated, so the -# branding (logo, eventually theme) shows up everywhere. -# -# Only a curated subset of settings is exposed here. Anything sensitive (api -# keys, internal URLs, admin tokens) MUST stay behind /api/admin/settings. -PUBLIC_SETTING_KEYS = ("ui.logo_url",) -_PUBLIC_DEFAULTS: Dict[str, Any] = {"ui.logo_url": "/logo.png"} - - -@router.get("/settings/public") -async def get_public_settings(db: AsyncSession = Depends(get_db_dep)): - """Return UI settings that are safe to expose without authentication. - - Used by the frontend to render the logo (and other public branding) on - every page, including login/register. The response shape is a flat - `{key: value}` dict. - """ - out: Dict[str, Any] = dict(_PUBLIC_DEFAULTS) - rows = await db.execute(select(Setting).where(Setting.key.in_(PUBLIC_SETTING_KEYS))) - for row in rows.scalars().all(): - out[row.key] = row.value - return out - - -@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() diff --git a/backend/app/api/presets.py b/backend/app/api/presets.py deleted file mode 100644 index 056f4f2..0000000 --- a/backend/app/api/presets.py +++ /dev/null @@ -1,71 +0,0 @@ -"""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 diff --git a/backend/app/api/sessions.py b/backend/app/api/sessions.py deleted file mode 100644 index 7eaf367..0000000 --- a/backend/app/api/sessions.py +++ /dev/null @@ -1,191 +0,0 @@ -"""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 generate_intro_scene, 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.post("/{session_id}/intro") -async def intro_session( - session_id: UUID, - db: AsyncSession = Depends(get_db_dep), - user: User = Depends(get_current_user), -): - """SSE stream that generates the opening cinematic scene for a new session.""" - 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") - - async def event_generator(): - try: - async for event in generate_intro_scene(db=db, user_id=user.id, session_id=session_id): - 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() diff --git a/backend/app/api/worlds.py b/backend/app/api/worlds.py deleted file mode 100644 index 976f819..0000000 --- a/backend/app/api/worlds.py +++ /dev/null @@ -1,217 +0,0 @@ -"""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.engine.world_editor import edit_world_via_chat, reset_editor_dialogue -from app.models import User, World -from app.schemas import ( - WorldBuilderCommit, - WorldBuilderMessage, - WorldBuilderReply, - WorldBuilderStart, - WorldCreate, - WorldEditorChatReply, - WorldEditorChatRequest, - 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}") - - - -# === World Editor (AI-assisted editing of an existing world) === - -@router.post("/{world_id}/chat", response_model=WorldEditorChatReply) -async def world_editor_chat( - world_id: UUID, - payload: WorldEditorChatRequest, - db: AsyncSession = Depends(get_db_dep), - user: User = Depends(get_current_user), -): - """Chat with the AI to edit an existing world's definition. - - Returns the AI's prose reply plus the proposed new definition. The - frontend must call PATCH /worlds/{id} to actually persist the change. - """ - 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") - try: - ai_message, new_defn, changed = await edit_world_via_chat( - db=db, user=user, world=world, 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"world_editor_chat_failed: {e}") - return WorldEditorChatReply( - ai_message=ai_message, - definition=new_defn, - changed=changed, - ) - - -@router.post("/{world_id}/chat/reset") -async def world_editor_chat_reset( - world_id: UUID, - db: AsyncSession = Depends(get_db_dep), - user: User = Depends(get_current_user), -): - """Clear the cached editor dialogue for a world (start fresh).""" - 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") - reset_editor_dialogue(world_id) - return {"ok": True} diff --git a/backend/app/config.py b/backend/app/config.py deleted file mode 100644 index b46a140..0000000 --- a/backend/app/config.py +++ /dev/null @@ -1,117 +0,0 @@ -"""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 -from pydantic_settings import BaseSettings, SettingsConfigDict - - -def _parse_cors_origins(raw: str) -> List[str]: - """Parse a CORS_ORIGINS env value into a list of origin strings. - - Accepts (in priority order): - - JSON array string: '["http://a","http://b"]' - - comma-separated: "http://a,http://b" - - single value: "http://a" - Strips whitespace and drops empties. Returns the default list if input - is empty/blank. - - This helper exists because pydantic-settings' EnvSettingsSource treats - `List[str]` as a "complex" type and tries to JSON-decode the raw env - value BEFORE any field validator runs — so a plain comma-separated - string from docker-compose crashes Settings() at import time on - older pydantic-settings versions. Storing the field as `str` sidesteps - that entirely; this helper parses it on demand. - """ - if not raw: - return ["http://localhost:5173"] - v = raw.strip() - if not v: - return ["http://localhost:5173"] - if v.startswith("["): - import json - try: - parsed = json.loads(v) - if isinstance(parsed, list): - return [str(o).strip() for o in parsed if str(o).strip()] - except Exception: - pass # fall through to comma-split - return [o.strip() for o in v.split(",") if o.strip()] - - -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 - # Stored as a raw string (NOT List[str]) so pydantic-settings' env source - # treats it as a simple scalar and never attempts JSON-decoding. The - # parsed list is exposed via the `cors_origins_list` property below. - # Accepts either a comma-separated string ("http://a,http://b") or a - # JSON-array string ('["http://a","http://b"]'). - cors_origins: str = "http://localhost:5173" - - @property - def cors_origins_list(self) -> List[str]: - """Parsed list of allowed CORS origins (see `_parse_cors_origins`).""" - return _parse_cors_origins(self.cors_origins) - - # 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() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/core/llm.py b/backend/app/core/llm.py deleted file mode 100644 index 7570ca9..0000000 --- a/backend/app/core/llm.py +++ /dev/null @@ -1,305 +0,0 @@ -"""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") - - -# vendor-specific end-of-sequence / control tokens that some local models -# emit into the content stream. Strip them so they don't leak to the user. -_EOS_TOKENS = ( - "", - "", - "<|endoftext|>", - "<|im_end|>", - "<|end|>", - "<|eot_id|>", - "<|eom_id|>", -) - - -def _clean_model_text(text: str) -> str: - """Remove vendor-specific end-of-sequence tokens and collapse whitespace. - - Some local models leak control tokens into the visible content stream. - We strip them so they never reach the user. - """ - if not text: - return text - cleaned = text - for tok in _EOS_TOKENS: - cleaned = cleaned.replace(tok, "") - while "\n\n\n" in cleaned: - cleaned = cleaned.replace("\n\n\n", "\n\n") - return cleaned - - - - -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: - # Use explicit timeout config so connect/read/write/pool timeouts - # are all visible — a bare `timeout=N` hides WHICH stage failed. - timeout = httpx.Timeout( - connect=10.0, # 10s to establish TCP connection - read=float(self.timeout), # full request timeout - write=10.0, - pool=5.0, - ) - async with httpx.AsyncClient(timeout=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 = _clean_model_text(msg.get("content") or "") - tool_calls = msg.get("tool_calls") or [] - usage = data.get("usage") or {} - except httpx.ConnectError as e: - err = f"ConnectError: {e}" - # Surface the URL + cause so the operator can see WHY (DNS, refused, etc.) - cause = getattr(e, "__cause__", None) or getattr(e, "__context__", None) - log.error( - "llm_connect_failed", - purpose=purpose, - url=url, - base_url=self.base_url, - model=self.model, - error=err, - cause=str(cause) if cause else None, - ) - raise - except Exception as e: - err = f"{type(e).__name__}: {e}" - log.error( - "llm_call_failed", - purpose=purpose, - url=url, - base_url=self.base_url, - model=self.model, - 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"): - piece = _clean_model_text(delta["content"]) - if piece: - full_text_parts.append(piece) - yield {"type": "delta", "content": piece} - 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, - }, - } diff --git a/backend/app/core/rag.py b/backend/app/core/rag.py deleted file mode 100644 index 7b4fec0..0000000 --- a/backend/app/core/rag.py +++ /dev/null @@ -1,463 +0,0 @@ -"""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}", - } diff --git a/backend/app/core/security.py b/backend/app/core/security.py deleted file mode 100644 index 7251ef7..0000000 --- a/backend/app/core/security.py +++ /dev/null @@ -1,44 +0,0 @@ -"""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 diff --git a/backend/app/core/settings_service.py b/backend/app/core/settings_service.py deleted file mode 100644 index 665a9bb..0000000 --- a/backend/app/core/settings_service.py +++ /dev/null @@ -1,95 +0,0 @@ -"""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, - # Note: triggers.check_interval was removed — triggers now fire in-process - # when in-game time changes, not via a polling worker. - # Embeddings / RAG - "embedding.provider": str, # "hash" | "openai" - "embedding.base_url": str, # OpenAI-compatible base URL (e.g. http://localhost:1234/v1) - "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 - # UI customization (logo URL/path shown in navbar + home page + favicon) - "ui.logo_url": str, # e.g. "/logo.png", "https://.../logo.png", or "data:image/png;base64,..." -} - - -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 diff --git a/backend/app/core/state_validator.py b/backend/app/core/state_validator.py deleted file mode 100644 index 2d1632d..0000000 --- a/backend/app/core/state_validator.py +++ /dev/null @@ -1,108 +0,0 @@ -"""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) diff --git a/backend/app/core/triggers.py b/backend/app/core/triggers.py deleted file mode 100644 index 4a0fac6..0000000 --- a/backend/app/core/triggers.py +++ /dev/null @@ -1,310 +0,0 @@ -"""World calendar + trigger firing helpers. - -Triggers fire on changes to in-world time (NOT real-time polling). When the -orchestrator advances world time (via the `advance_time` tool or the -`time_advance` field in a plan), the engine checks all unfired triggers for -that session and fires any whose `fire_at` is now <= the new world time. - -Each world may define its own calendar via `world.definition.calendar`: - { - "hours_per_day": 24, # default 24 - "days_per_week": 7, # informational only (not used in math) - "minutes_per_hour": 60 # default 60 - } - -World time is stored as a string "day_{D}_hour_{H}" (we don't track minutes -in the string to keep it compact — minutes are tracked separately in -world.state.world_time if needed). - -The trigger's `fire_at` is also a "day_D_hour_H" string. We compare by -totaling the in-world minutes since day-0-hour-0 for each side. -""" -from __future__ import annotations - -import json -import re -import uuid -from typing import Any, Dict, List, Optional, Tuple - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.llm import LlmClient -from app.core.settings_service import get_all_settings -from app.core.state_validator import apply_patch, validate_state -from app.engine.tools.tools import TRIGGER_RUNNER_TOOL_SCHEMAS -from app.logging_setup import get_logger -from app.models import DeferredTrigger, Message, Session, World -from app.prompts.templates import get_prompt - -log = get_logger("triggers") - - -_TIME_RE = re.compile(r"^day_(\d+)_hour_(\d+)(?:_min_(\d+))?$") - - -def get_calendar(world: World) -> Dict[str, int]: - """Return the world's calendar config with defaults applied.""" - defn = world.definition or {} - cal = (defn.get("calendar") or {}) if isinstance(defn, dict) else {} - return { - "hours_per_day": int(cal.get("hours_per_day", 24) or 24), - "minutes_per_hour": int(cal.get("minutes_per_hour", 60) or 60), - "days_per_week": int(cal.get("days_per_week", 7) or 7), - } - - -def parse_world_time(t: Optional[str], cal: Dict[str, int]) -> int: - """Parse 'day_D_hour_H[_min_M]' into total in-world minutes since day 0 hour 0. - - Returns 0 for unparseable input (so triggers with bad fire_at fire - immediately rather than never — fail-open for visibility). - """ - if not t: - return 0 - m = _TIME_RE.match(t.strip()) - if not m: - # Try ISO datetime as a fallback (rare). - try: - from datetime import datetime - return int(datetime.fromisoformat(t).timestamp() // 60) - except Exception: - return 0 - day = int(m.group(1)) - hour = int(m.group(2)) - minute = int(m.group(3) or 0) - hours_per_day = max(1, cal.get("hours_per_day", 24)) - minutes_per_hour = max(1, cal.get("minutes_per_hour", 60)) - return day * hours_per_day * minutes_per_hour + hour * minutes_per_hour + minute - - -def format_world_time(total_minutes: int, cal: Dict[str, int]) -> str: - """Inverse of parse_world_time: total minutes -> 'day_D_hour_H' string.""" - hours_per_day = max(1, cal.get("hours_per_day", 24)) - minutes_per_hour = max(1, cal.get("minutes_per_hour", 60)) - minutes_per_day = hours_per_day * minutes_per_hour - day = total_minutes // minutes_per_day - rem = total_minutes % minutes_per_day - hour = rem // minutes_per_hour - minute = rem % minutes_per_hour - if minute: - return f"day_{day}_hour_{hour}_min_{minute}" - return f"day_{day}_hour_{hour}" - - -def advance_world_time( - current_time: Optional[str], - advance: Dict[str, int], - world: World, -) -> Tuple[str, int, int]: - """Advance world time by days/hours/minutes, honoring the world's calendar. - - Returns (new_time_string, new_total_minutes, delta_minutes). - """ - cal = get_calendar(world) - cur_total = parse_world_time(current_time, cal) - hours_per_day = cal["hours_per_day"] - minutes_per_hour = cal["minutes_per_hour"] - delta = ( - int(advance.get("days", 0)) * hours_per_day * minutes_per_hour - + int(advance.get("hours", 0)) * minutes_per_hour - + int(advance.get("minutes", 0)) - ) - new_total = cur_total + delta - new_str = format_world_time(new_total, cal) - - # Also update world_time in state if present. - if world.state and isinstance(world.state, dict) and "world_time" in world.state: - wt = world.state["world_time"] - if isinstance(wt, dict): - day = new_total // (hours_per_day * minutes_per_hour) - rem = new_total % (hours_per_day * minutes_per_hour) - hour = rem // minutes_per_hour - minute = rem % minutes_per_hour - wt["day"] = day - wt["hour"] = hour - wt["minute"] = minute - wt["hours_per_day"] = hours_per_day - wt["minutes_per_hour"] = minutes_per_hour - - return new_str, new_total, delta - - -async def fire_due_triggers( - db: AsyncSession, - session_id: uuid.UUID, - world: World, - settings_map: Optional[Dict[str, Any]] = None, - user_id: Optional[uuid.UUID] = None, -) -> List[Dict[str, Any]]: - """Fire all due triggers for this session. - - "Due" = trigger.fired is False AND parse_world_time(trigger.fire_at) <= - parse_world_time(world.current_time). - - Each fired trigger: - 1. Calls the LLM (trigger_runner prompt) to produce narrative + state patch. - 2. Applies the state patch to the world. - 3. Saves a Message (visible if should_notify_player, hidden otherwise). - 4. Marks trigger.fired = True. - - Returns a list of fired trigger dicts (for the orchestrator to include in - the step_complete event). - """ - cal = get_calendar(world) - cur_total = parse_world_time(world.current_time, cal) - - result = await db.execute( - select(DeferredTrigger).where( - DeferredTrigger.session_id == session_id, - DeferredTrigger.fired.is_(False), - ) - ) - triggers = list(result.scalars().all()) - if not triggers: - return [] - - # Sort by fire_at ascending so they fire in chronological order. - triggers.sort(key=lambda t: parse_world_time(t.fire_at, cal)) - - fired: List[Dict[str, Any]] = [] - for trigger in triggers: - if parse_world_time(trigger.fire_at, cal) > cur_total: - continue # not due yet - try: - await _fire_one(db, trigger, session_id, world, settings_map, user_id) - fired.append({ - "id": str(trigger.id), - "fire_at": trigger.fire_at, - "description": trigger.description, - "payload": trigger.payload, - }) - except Exception as e: - log.error( - "trigger_fire_failed", - trigger_id=str(trigger.id), - session_id=str(session_id), - error=f"{type(e).__name__}: {e}", - ) - # Mark as fired anyway so we don't retry forever on a broken trigger. - trigger.fired = True - if fired: - await db.commit() - return fired - - -async def _fire_one( - db: AsyncSession, - trigger: DeferredTrigger, - session_id: uuid.UUID, - world: World, - settings_map: Optional[Dict[str, Any]], - user_id: Optional[uuid.UUID], -) -> None: - """Fire a single trigger: produce narrative + apply state patch. - - Uses the `submit_trigger_result` tool (tool-calling-first design) to get - structured output from the LLM. - """ - if settings_map is None: - settings_map = await get_all_settings(db) - llm = LlmClient(settings_map) - - # Trigger-runner prompt is always English (system content convention); - # the LLM produces player-facing narrative in world.language (interpolated). - system_prompt = get_prompt("trigger_runner", "en").format( - description=trigger.description, - payload=json.dumps(trigger.payload, ensure_ascii=False)[:600], - state=json.dumps(world.state, ensure_ascii=False)[:1000], - world_language=world.language, - ) - - response = await llm.chat( - messages=[{"role": "system", "content": system_prompt}], - tools=TRIGGER_RUNNER_TOOL_SCHEMAS, - temperature=0.5, - max_tokens=600, - purpose="trigger", - user_id=user_id, - session_id=session_id, - db=db, - ) - - # Extract from submit_trigger_result tool call; fall back to JSON parse. - parsed: Dict[str, Any] = {} - extracted = False - for tc in (response.tool_calls or []): - if tc.get("function", {}).get("name") == "submit_trigger_result": - args_str = tc.get("function", {}).get("arguments", "{}") - try: - parsed = json.loads(args_str) if args_str else {} - extracted = True - except json.JSONDecodeError: - pass - break - if not extracted: - m = re.search(r"\{[\s\S]*\}", response.text or "") - if m: - try: - parsed = json.loads(m.group(0)) - except json.JSONDecodeError: - pass - - # Apply state patch - state_patch = parsed.get("state_patch", {}) or {} - if state_patch: - new_state = apply_patch(world.state, state_patch) - schema = world.definition.get("world_schema", {}) - ok, _errors = validate_state(new_state, schema) - if ok: - world.state = new_state - - narrative = parsed.get("narrative", "") or "" - should_notify = bool(parsed.get("should_notify_player", True)) - - # Compute next message seq - seq_result = await db.execute( - select(Message.seq) - .where(Message.session_id == session_id) - .order_by(Message.seq.desc()) - .limit(1) - ) - row = seq_result.first() - next_seq = (row[0] + 1) if row else 1 - - if should_notify and narrative: - msg = Message( - session_id=session_id, - seq=next_seq, - role="system", - kind="narrative_step", - content=narrative, - payload={ - "trigger_id": str(trigger.id), - "triggered_at": trigger.fire_at, - "outcome": parsed.get("outcome", trigger.description), - "world_time": world.current_time, - "player_state": world.state.get("player", {}), - "options": [], - }, - is_pinned=True, - hidden=False, - ) - else: - msg = Message( - session_id=session_id, - seq=next_seq, - role="system", - kind="technical_offscreen", - content=f"[Trigger fired: {trigger.description}] Outcome: {parsed.get('outcome', '')}", - payload={ - "trigger_id": str(trigger.id), - "outcome": parsed.get("outcome", ""), - "state_patch": state_patch, - }, - is_pinned=False, - hidden=True, - ) - db.add(msg) - trigger.fired = True - log.info("trigger_fired", trigger_id=str(trigger.id), session_id=str(session_id)) diff --git a/backend/app/db.py b/backend/app/db.py deleted file mode 100644 index 631f024..0000000 --- a/backend/app/db.py +++ /dev/null @@ -1,49 +0,0 @@ -"""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() diff --git a/backend/app/db_wait.py b/backend/app/db_wait.py deleted file mode 100644 index 96dc8f5..0000000 --- a/backend/app/db_wait.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Database readiness helper: wait until the DB is reachable and tables exist. - -Used by the worker process to avoid crashing when the backend hasn't yet -finished running `init_db()` (which creates tables). Worker starts in parallel -with backend in docker-compose and may come up first. -""" -from __future__ import annotations - -import asyncio - -from sqlalchemy import inspect, select, text - -from app.db import AsyncSessionLocal, Base, engine -from app.logging_setup import get_logger -from app.models import Setting - -log = get_logger("db_wait") - - -async def wait_for_db( - max_retries: int = 60, - delay: float = 2.0, - required_tables: tuple[str, ...] = ("settings",), -) -> None: - """Block until the database is reachable AND all `required_tables` exist. - - Retries on connection errors and on missing-table errors. Logs progress. - Raises the last error after `max_retries` attempts. - """ - last_err: Exception | None = None - for attempt in range(1, max_retries + 1): - try: - # Check raw connectivity - async with engine.connect() as conn: - await conn.execute(text("SELECT 1")) - - # Check that required tables exist - async with engine.connect() as conn: - existing = await conn.run_sync( - lambda sync_conn: set(inspect(sync_conn).get_table_names()) - ) - missing = [t for t in required_tables if t not in existing] - if missing: - raise RuntimeError(f"required tables not yet created: {missing}") - - # Smoke-test the `settings` table specifically (the worker's first query) - async with AsyncSessionLocal() as db: - await db.execute(select(Setting).limit(1)) - - log.info("db_ready", attempt=attempt) - return - except Exception as e: - last_err = e - log.warning( - "db_not_ready_retry", - attempt=attempt, - max_retries=max_retries, - error=f"{type(e).__name__}: {e}", - ) - await asyncio.sleep(delay) - - # Exhausted retries — surface the last error so the caller can decide. - assert last_err is not None - raise last_err - - -async def wait_for_db_or_exit( - max_retries: int = 60, - delay: float = 2.0, - required_tables: tuple[str, ...] = ("settings",), -) -> None: - """Like `wait_for_db`, but exits the process with code 1 on failure. - - Useful as the very first call in the worker entrypoint so it doesn't - spam logs forever if the DB is genuinely unreachable. - """ - try: - await wait_for_db(max_retries=max_retries, delay=delay, required_tables=required_tables) - except Exception as e: - log.error("db_wait_exhausted", error=f"{type(e).__name__}: {e}") - raise SystemExit(1) diff --git a/backend/app/deps.py b/backend/app/deps.py deleted file mode 100644 index f0b33f3..0000000 --- a/backend/app/deps.py +++ /dev/null @@ -1,40 +0,0 @@ -"""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 diff --git a/backend/app/engine/__init__.py b/backend/app/engine/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/engine/context.py b/backend/app/engine/context.py deleted file mode 100644 index aca0839..0000000 --- a/backend/app/engine/context.py +++ /dev/null @@ -1,258 +0,0 @@ -"""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. - # NOTE: prompts are always English (system content convention). The LLM - # produces player-facing text in world.language when relevant (the - # step-writer prompt interpolates world_language explicitly). - defn = world.definition or {} - system_prompt_template = get_prompt("orchestrator", world.language) - player_state = world.state.get("player", {}) if world.state else {} - 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 "(no summary yet)", - ) - - messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}] - - # Add summary as a system note if present - if summary_text: - messages.append({"role": "system", "content": f"Past summary:\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'Player action: "{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. - - Uses the `submit_summary` tool (tool-calling-first design) to get structured - output from the summarizer LLM. - """ - visible = [m for m in all_msgs if not m.hidden] - if len(visible) <= recent_n + summary_n: - return - - # 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 (English labels — system content convention) - summary_input_lines = [] - for m in to_summarize: - prefix = { - "player_action": "Player", - "narrative_step": "Scene", - "summary": "Summary", - "orchestrator_plan": "GM", - "technical_offscreen": "Offscreen", - }.get(m.kind, m.kind) - summary_input_lines.append(f"{prefix}: {m.content[:300]}") - summary_input = "\n\n".join(summary_input_lines) - - llm = LlmClient(settings_map) - system_prompt = get_prompt("summarizer", world.language) - from app.engine.tools.tools import SUMMARIZER_TOOL_SCHEMAS - response = await llm.chat( - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": summary_input[:4000]}, - ], - tools=SUMMARIZER_TOOL_SCHEMAS, - temperature=float(cast_setting("llm.summary_temperature", settings_map.get("llm.summary_temperature", 0.3))), - max_tokens=400, - purpose="summary", - session_id=session_id, - db=db, - ) - - # Extract from submit_summary tool call; fall back to text parse. - summary_text = response.text or "" - facts: List[Dict[str, Any]] = [] - extracted = False - for tc in (response.tool_calls or []): - if tc.get("function", {}).get("name") == "submit_summary": - args_str = tc.get("function", {}).get("arguments", "{}") - try: - data = json.loads(args_str) if args_str else {} - summary_text = data.get("summary", response.text or "") - facts = data.get("facts", []) or [] - extracted = True - except json.JSONDecodeError: - pass - break - if not extracted: - # Fallback: extract JSON from text response (older models). - import re as _re - json_match = _re.search(r"\{[\s\S]*\}", response.text or "") - if json_match: - try: - data = json.loads(json_match.group(0)) - summary_text = data.get("summary", response.text or "") - facts = data.get("facts", []) or [] - except json.JSONDecodeError: - pass - - # Create summary message - next_seq = (max((m.seq for m in all_msgs), default=0)) + 1 - 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. - - The step-writer prompt is in English (system content convention) but - instructs the LLM to produce the narrative in world.language. - """ - defn = world.definition or {} - player_state = world.state.get("player", {}) if world.state else {} - system_prompt = get_prompt("step_writer", world.language).format( - 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], - world_language=world.language or "en", - ) - 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}] diff --git a/backend/app/engine/orchestrator.py b/backend/app/engine/orchestrator.py deleted file mode 100644 index 448b4b7..0000000 --- a/backend/app/engine/orchestrator.py +++ /dev/null @@ -1,615 +0,0 @@ -"""Game orchestrator: runs the multi-step LLM tool-calling loop and produces a narrative step. - -Design (v2 — tool-calling-first): - - The orchestrator LLM is given a set of game tools (dice_roll, update_state, - rag_query, rag_add, schedule_trigger, advance_time, run_subagent) PLUS a - `submit_plan` tool. The LLM calls game tools to execute its plan, then - calls `submit_plan` to terminate the loop with structured data. - - The step-writer LLM is given only a `submit_scene` tool. It calls this - to return the narrative + options; its text response is ignored. - - This replaces the old "return JSON in your text response" pattern which - conflicted with tool use and caused the model to dump raw JSON into chat. -""" -from __future__ import annotations - -import json -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.core.triggers import advance_world_time, fire_due_triggers -from app.engine.context import ( - build_orchestrator_messages, - build_step_writer_messages, - build_subagent_messages, -) -from app.engine.tools.tools import ( - ALL_TOOL_SCHEMAS, - STEP_WRITER_TOOL_SCHEMAS, - ToolContext, - handle_tool_call, -) -from app.logging_setup import get_logger -from app.models import Message, Session, World - -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 — UNLESS this is a retry of the - # previous action (frontend re-sent the same action_text after an error). - # In that case we reuse the existing player_action row so the chat - # history doesn't fill up with duplicates. - last_msg_result = await db.execute( - select(Message) - .where(Message.session_id == session_id) - .order_by(Message.seq.desc()) - .limit(1) - ) - last_msg = last_msg_result.scalars().first() - is_retry = ( - last_msg is not None - and last_msg.kind == "player_action" - and last_msg.content == action_text - ) - if is_retry: - player_msg = last_msg - else: - 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) - - max_iters = 5 - parsed: Dict[str, Any] = {} - plan_tool_calls_log: List[Dict[str, Any]] = [] - orchestrator_text_log: str = "" - - for i in range(max_iters): - yield {"type": "status", "data": {"message": f"orchestrator_turn_{i + 1}"}} - 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 not response.tool_calls: - # No tool calls — model gave up or errored. Treat its text as the - # outcome directly so the player still sees SOMETHING. - log.warning("orchestrator_no_tool_calls", iteration=i, text_len=len(response.text or "")) - orchestrator_text_log = response.text or "" - parsed = { - "assessment": "(no plan submitted)", - "outcome": response.text or "", - "narrative_prompt": "", - "next_options": [], - "state_patch": {}, - "time_advance": None, - "rag_facts": [], - "rails_update": None, - } - break - - # Append assistant message with tool_calls - orchestrator_messages.append({ - "role": "assistant", - "content": response.text or "", - "tool_calls": response.tool_calls, - }) - - # Check for submit_plan — if present, extract plan and break - submit_plan_call = None - for tc in response.tool_calls: - if tc.get("function", {}).get("name") == "submit_plan": - submit_plan_call = tc - break - - if submit_plan_call: - # Extract plan from the submit_plan tool call - args_str = submit_plan_call.get("function", {}).get("arguments", "{}") - try: - parsed = json.loads(args_str) if args_str else {} - except json.JSONDecodeError: - log.warning("submit_plan_invalid_json", args=args_str[:200]) - parsed = {} - # Make sure required keys exist - parsed.setdefault("assessment", "") - parsed.setdefault("outcome", "") - parsed.setdefault("narrative_prompt", "") - parsed.setdefault("next_options", []) - parsed.setdefault("state_patch", {}) - parsed.setdefault("time_advance", None) - parsed.setdefault("rag_facts", []) - parsed.setdefault("rails_update", None) - # Acknowledge the tool call so the model's history is consistent - orchestrator_messages.append({ - "role": "tool", - "tool_call_id": submit_plan_call.get("id", ""), - "name": "submit_plan", - "content": json.dumps({"ok": True}), - }) - # Log OTHER tool calls made this iteration (for debugging) - for tc in response.tool_calls: - fn = tc.get("function", {}) - if fn.get("name") != "submit_plan": - plan_tool_calls_log.append({ - "name": fn.get("name"), - "args": _safe_parse_json(fn.get("arguments", "{}")), - }) - break - - # Otherwise: execute all tool calls and continue - for tc in response.tool_calls: - fn = tc.get("function", {}) - name = fn.get("name", "") - args_str = fn.get("arguments", "{}") - try: - args = json.loads(args_str) if args_str else {} - except json.JSONDecodeError: - args = {} - yield {"type": "tool_call", "data": {"name": name, "args": args}} - try: - result_dict = await handle_tool_call(name, args, ctx) - except Exception as e: - result_dict = {"error": f"{type(e).__name__}: {e}"} - log.error("tool_call_failed", name=name, error=str(e)) - yield {"type": "tool_result", "data": {"name": name, "result": result_dict}} - plan_tool_calls_log.append({"name": name, "args": args, "result": result_dict}) - # Append tool result message - orchestrator_messages.append({ - "role": "tool", - "tool_call_id": tc.get("id", ""), - "name": name, - "content": json.dumps(result_dict, ensure_ascii=False, default=str)[:800], - }) - await db.commit() - else: - # Ran out of iterations without submit_plan — use a minimal fallback. - log.warning("orchestrator_exhausted_iterations") - parsed = parsed or { - "assessment": "(iteration limit reached)", - "outcome": orchestrator_text_log or action_text, - "narrative_prompt": "", - "next_options": [], - "state_patch": {}, - "time_advance": None, - "rag_facts": [], - "rails_update": None, - } - - yield {"type": "status", "data": {"message": "writing_scene"}} - - # 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, _total, _delta = 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=(parsed.get("assessment", "") + " | " + parsed.get("outcome", ""))[:2000], - payload={ - "assessment": parsed.get("assessment", ""), - "outcome": parsed.get("outcome", ""), - "state_patch": parsed.get("state_patch", {}), - "time_advance": time_advance, - "tool_calls_made": plan_tool_calls_log, - "scheduled_triggers": ctx.scheduled_triggers, - "rag_added": ctx.rag_added, - "narrative_prompt": parsed.get("narrative_prompt", ""), - "next_options": parsed.get("next_options", []), - }, - is_pinned=False, - hidden=True, - ) - db.add(plan_msg) - - # === Phase 2: Step writer (narrative scene) — uses submit_scene tool === - narrative_prompt_parts = [parsed.get("narrative_prompt", "")] - # Add RAG context if relevant - if parsed.get("outcome"): - try: - from app.core.rag import get_rag - rag = await get_rag(settings_map) - rag_results = await rag.search_glossary( - world.id, - parsed.get("outcome", ""), - limit=3, - settings_map=settings_map, - ) - 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, - tools=STEP_WRITER_TOOL_SCHEMAS, - temperature=float(cast_setting("llm.step_temperature", settings_map.get("llm.step_temperature", 0.85))), - max_tokens=1200, - purpose="step", - user_id=user_id, - session_id=session_id, - db=db, - ) - - # Extract scene from submit_scene tool call (if present); fall back to text. - step_text = step_resp.text or "" - step_options: List[str] = parsed.get("next_options", []) or [] - for tc in (step_resp.tool_calls or []): - if tc.get("function", {}).get("name") == "submit_scene": - args_str = tc.get("function", {}).get("arguments", "{}") - try: - scene_data = json.loads(args_str) if args_str else {} - if scene_data.get("narrative"): - step_text = scene_data["narrative"] - if scene_data.get("options") and isinstance(scene_data["options"], list): - step_options = [str(o) for o in scene_data["options"]][:5] - except json.JSONDecodeError: - log.warning("submit_scene_invalid_json", args=args_str[:200]) - break - else: - # No submit_scene call — try to extract JSON from text as a last resort. - import re as _re - json_match = _re.search(r"\{[\s\S]*\}", step_resp.text or "") - if json_match: - try: - step_data = json.loads(json_match.group(0)) - if "narrative" in step_data: - step_text = step_data["narrative"] - if "options" in step_data and isinstance(step_data["options"], list): - step_options = [str(o) for o in step_data["options"]][:5] - except json.JSONDecodeError: - pass - # If still no narrative, use the orchestrator's outcome as fallback. - if not step_text.strip(): - step_text = parsed.get("outcome", action_text) - - # Save narrative step message - step_seq = await _next_seq(db, session_id) - 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) - 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 now (fire_at <= current world time). - # Triggers fire on in-game time changes, not real-time polling — see - # app.core.triggers. We do this AFTER committing the narrative step so the - # player sees the main scene first, then any trigger consequences. - triggers_enabled = bool(cast_setting( - "triggers.enabled", - settings_map.get("triggers.enabled", True), - )) - fired_now: List[Dict[str, Any]] = [] - if triggers_enabled: - try: - await db.refresh(world) - fired_now = await fire_due_triggers( - db=db, - session_id=session_id, - world=world, - settings_map=settings_map, - user_id=user_id, - ) - except Exception as e: - log.warning("trigger_fire_failed_in_iteration", error=f"{type(e).__name__}: {e}") - - yield { - "type": "step_complete", - "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": {}} - - -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 _safe_parse_json(s: str) -> Any: - try: - return json.loads(s) if s else {} - except Exception: - return s - - - -async def generate_intro_scene( - db: AsyncSession, - user_id: uuid.UUID, - session_id: uuid.UUID, -) -> AsyncIterator[Dict[str, Any]]: - """Generate the opening cinematic scene for a freshly-created session. - - Yields the same SSE event stream shape as `run_iteration` so the - frontend can consume it identically. Saves a `narrative_step` message - of kind `intro_scene` (still kind=narrative_step for compatibility, - but with payload.kind=intro so the UI can style it differently if - desired). - """ - 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) - - yield {"type": "status", "data": {"message": "writing_scene"}} - - import json as _json - defn = world.definition or {} - player_state = world.state.get("player", {}) if world.state else {} - system_prompt = get_prompt("intro_scene", world.language).format( - setting_description=defn.get("setting_description", "")[:1200], - current_time=world.current_time or "", - player_state=_json.dumps(player_state, ensure_ascii=False)[:800], - plot_rails=_json.dumps(defn.get("plot_rails", {}), ensure_ascii=False)[:600], - world_language=world.language or "en", - ) - - step_resp = await llm.chat( - messages=[{"role": "system", "content": system_prompt}], - tools=STEP_WRITER_TOOL_SCHEMAS, - temperature=float(cast_setting("llm.step_temperature", settings_map.get("llm.step_temperature", 0.85))), - max_tokens=1500, - purpose="intro_scene", - user_id=user_id, - session_id=session_id, - db=db, - ) - - step_text = step_resp.text or "" - step_options: List[str] = [] - for tc in (step_resp.tool_calls or []): - if tc.get("function", {}).get("name") == "submit_scene": - args_str = tc.get("function", {}).get("arguments", "{}") - try: - scene_data = _json.loads(args_str) if args_str else {} - if scene_data.get("narrative"): - step_text = scene_data["narrative"] - if scene_data.get("options") and isinstance(scene_data["options"], list): - step_options = [str(o) for o in scene_data["options"]][:5] - except _json.JSONDecodeError: - log.warning("intro_scene_invalid_json", args=args_str[:200]) - break - else: - # Fallback: extract JSON from text. - import re as _re - json_match = _re.search(r"\{[\s\S]*\}", step_resp.text or "") - if json_match: - try: - step_data = _json.loads(json_match.group(0)) - if "narrative" in step_data: - step_text = step_data["narrative"] - if "options" in step_data and isinstance(step_data["options"], list): - step_options = [str(o) for o in step_data["options"]][:5] - except _json.JSONDecodeError: - pass - - # Save as a narrative_step message flagged as intro in payload. - 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={ - "kind": "intro", - "options": step_options, - "world_time": world.current_time, - "player_state": world.state.get("player", {}), - }, - is_pinned=True, - hidden=False, - ) - db.add(step_msg) - session.last_played_at = datetime.now(timezone.utc) - await db.commit() - await db.refresh(step_msg) - - 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": [], - "is_intro": True, - }, - } - yield {"type": "done", "data": {}} diff --git a/backend/app/engine/tools/__init__.py b/backend/app/engine/tools/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/engine/tools/tools.py b/backend/app/engine/tools/tools.py deleted file mode 100644 index e2aa2d2..0000000 --- a/backend/app/engine/tools/tools.py +++ /dev/null @@ -1,545 +0,0 @@ -"""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 in-world time. When the world's " - "internal clock reaches fire_at, the engine fires the event (calls the " - "LLM with the description to produce a narrative beat and optional " - "state patch). fire_at must use the same format as world.current_time " - "('day_N_hour_H' or 'day_N_hour_H_min_M'). The world's calendar " - "(hours_per_day, minutes_per_hour) is honored when comparing times." - ), - params={ - "type": "object", - "properties": { - "fire_at": { - "type": "string", - "description": "In-world time when the trigger fires, e.g. 'day_3_hour_14' or 'day_3_hour_14_min_30'.", - }, - "description": { - "type": "string", - "description": "What should happen when the trigger fires. Be specific — this is fed to the LLM at fire time.", - }, - "payload": { - "type": "object", - "description": "Optional structured payload (e.g. who, conditions, parameters).", - }, - }, - "required": ["fire_at", "description"], - }, -) - - -ADVANCE_TIME_SCHEMA = build_tool_schema( - name="advance_time", - description=( - "Advance the world's internal clock by days / hours / minutes. Use " - "this when the player's action takes measurable in-world time (travel, " - "sleep, crafting, long rest). The world's calendar (hours_per_day, " - "minutes_per_hour) is honored. After time advances, any scheduled " - "triggers whose fire_at is now <= the new time will fire " - "automatically — so this is also how you 'run out the clock' on a " - "scheduled event." - ), - params={ - "type": "object", - "properties": { - "days": {"type": "integer", "default": 0}, - "hours": {"type": "integer", "default": 0}, - "minutes": {"type": "integer", "default": 0}, - "reason": {"type": "string", "description": "Why time advances (logged for debugging)."}, - }, - }, -) - - -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"], - }, -) - - -# === Submission tools (how the LLM returns structured results) === -# These replace the old "return JSON in your text response" pattern, which -# conflicted with tool use and caused the model to dump raw JSON into chat. - -SUBMIT_PLAN_SCHEMA = build_tool_schema( - name="submit_plan", - description=( - "Submit the orchestrator's final plan for this iteration. This MUST be " - "the last tool you call. After you call it, the iteration ends and the " - "step-writer takes over to produce the cinematic scene." - ), - params={ - "type": "object", - "properties": { - "assessment": { - "type": "string", - "description": "Brief assessment of the player's action (1-2 sentences, English).", - }, - "outcome": { - "type": "string", - "description": "What concretely happened (1-3 sentences, English). Fed to the step-writer as the raw outcome.", - }, - "state_patch": { - "type": "object", - "description": "JSON-patch for world state. Keys: set, unset, append, increment, remove. Empty object if no change.", - "properties": { - "set": {"type": "object"}, - "unset": {"type": "array", "items": {"type": "string"}}, - "append": {"type": "object"}, - "increment": {"type": "object"}, - "remove": {"type": "object"}, - }, - }, - "time_advance": { - "type": "object", - "description": "How much in-world time advances. null/omitted if no time passes.", - "properties": { - "days": {"type": "integer", "default": 0}, - "hours": {"type": "integer", "default": 0}, - "minutes": {"type": "integer", "default": 0}, - }, - }, - "narrative_prompt": { - "type": "string", - "description": "Facts the step-writer should know to write the scene (English). Max ~100 words.", - }, - "next_options": { - "type": "array", - "items": {"type": "string"}, - "description": "3 suggested next actions for the player (short, 5-12 words each).", - }, - "rails_update": { - "type": "object", - "description": "Optional update to plot rails. Omit if no change.", - "properties": { - "main_goal": {"type": "string"}, - "new_subgoals": {"type": "array", "items": {"type": "string"}}, - "completed_subgoals": {"type": "array", "items": {"type": "string"}}, - }, - }, - "rag_facts": { - "type": "array", - "description": "New persistent facts to add to the glossary. Empty array if none.", - "items": { - "type": "object", - "properties": { - "kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event"]}, - "name": {"type": "string"}, - "description": {"type": "string"}, - }, - "required": ["kind", "name", "description"], - }, - }, - }, - "required": ["assessment", "outcome", "narrative_prompt", "next_options"], - }, -) - - -SUBMIT_SCENE_SCHEMA = build_tool_schema( - name="submit_scene", - description=( - "Submit the narrative scene for this step. This is the ONLY way to " - "return the scene — your text response is ignored. The narrative " - "should be 200-400 words, cinematic, second-person ('You...'), in the " - "world's player-facing language." - ), - params={ - "type": "object", - "properties": { - "narrative": { - "type": "string", - "description": "200-400 words of cinematic prose describing the scene. Second-person ('You...').", - }, - "options": { - "type": "array", - "items": {"type": "string"}, - "description": "Exactly 3 short (5-12 words) options for the player's next action.", - }, - }, - "required": ["narrative", "options"], - }, -) - - -SUBMIT_WORLD_DEFINITION_SCHEMA = build_tool_schema( - name="submit_world_definition", - description=( - "Submit a proposed world definition. Call this when you have enough " - "information to build the world. Your text response will be shown to " - "the player as your conversational reply (use it to summarize the " - "proposed world in 2-4 sentences)." - ), - params={ - "type": "object", - "properties": { - "setting_description": {"type": "string", "description": "Expanded setting, 1-2 paragraphs."}, - "rules": { - "type": "object", - "description": "Object with keys like stats, combat, magic, time, inventory, death (whichever apply).", - }, - "world_schema": { - "type": "object", - "description": "JSON Schema describing the shape of the world state.", - }, - "plot_rails": { - "type": "object", - "description": "{main_goal, subgoals, hooks}.", - "properties": { - "main_goal": {"type": "string"}, - "subgoals": {"type": "array", "items": {"type": "string"}}, - "hooks": {"type": "array", "items": {"type": "string"}}, - }, - }, - "initial_state": { - "type": "object", - "description": "Initial world state matching world_schema.", - }, - "initial_time": { - "type": "string", - "description": "World time string e.g. 'day_1_hour_8'.", - }, - "calendar": { - "type": "object", - "description": "Optional. Custom calendar. Include only if non-standard.", - "properties": { - "hours_per_day": {"type": "integer"}, - "minutes_per_hour": {"type": "integer"}, - "days_per_week": {"type": "integer"}, - }, - }, - "is_final": { - "type": "boolean", - "description": "True ONLY when the player has explicitly accepted the world.", - }, - }, - "required": ["setting_description", "rules", "world_schema", "initial_state", "initial_time"], - }, -) - - -SUBMIT_SUMMARY_SCHEMA = build_tool_schema( - name="submit_summary", - description="Submit the compressed summary of older session messages.", - params={ - "type": "object", - "properties": { - "summary": {"type": "string", "description": "3-6 sentences, max 150 words."}, - "facts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event"]}, - "name": {"type": "string"}, - "description": {"type": "string"}, - }, - "required": ["kind", "name", "description"], - }, - }, - }, - "required": ["summary", "facts"], - }, -) - - -SUBMIT_TRIGGER_RESULT_SCHEMA = build_tool_schema( - name="submit_trigger_result", - description="Submit the result of firing a deferred trigger.", - params={ - "type": "object", - "properties": { - "outcome": {"type": "string", "description": "1-2 sentences, English. For logs."}, - "state_patch": { - "type": "object", - "description": "JSON-patch for world state. Empty object if no change.", - "properties": { - "set": {"type": "object"}, - "unset": {"type": "array", "items": {"type": "string"}}, - "append": {"type": "object"}, - "increment": {"type": "object"}, - "remove": {"type": "object"}, - }, - }, - "narrative": {"type": "string", "description": "1-paragraph scene description for the player, in world.language. Empty string if offscreen."}, - "should_notify_player": {"type": "boolean", "description": "True if the player should see the narrative."}, - }, - "required": ["outcome", "narrative", "should_notify_player"], - }, -) - - -# Tools available to the orchestrator (game-loop tools + submit_plan) -ALL_TOOL_SCHEMAS = [ - DICE_ROLL_SCHEMA, - UPDATE_STATE_SCHEMA, - RAG_QUERY_SCHEMA, - RAG_ADD_SCHEMA, - SCHEDULE_TRIGGER_SCHEMA, - ADVANCE_TIME_SCHEMA, - RUN_SUBAGENT_SCHEMA, - SUBMIT_PLAN_SCHEMA, -] - -# Tools for the step writer (only submit_scene) -STEP_WRITER_TOOL_SCHEMAS = [SUBMIT_SCENE_SCHEMA] - -# Tools for the world builder (only submit_world_definition) -WORLD_BUILDER_TOOL_SCHEMAS = [SUBMIT_WORLD_DEFINITION_SCHEMA] - -# Tools for the summarizer -SUMMARIZER_TOOL_SCHEMAS = [SUBMIT_SUMMARY_SCHEMA] - -# Tools for the trigger runner -TRIGGER_RUNNER_TOOL_SCHEMAS = [SUBMIT_TRIGGER_RESULT_SCHEMA] - - -# === Tool handlers === - -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) diff --git a/backend/app/engine/world_builder.py b/backend/app/engine/world_builder.py deleted file mode 100644 index 26fb5d8..0000000 --- a/backend/app/engine/world_builder.py +++ /dev/null @@ -1,463 +0,0 @@ -"""World builder: multi-turn dialogue to produce a finalized WorldDefinition. - -Design (v2 — tool-calling-first): - The world-builder LLM is given a single tool, `submit_world_definition`, - which it calls when it has enough information to propose a world. The LLM's - text response is the conversational reply shown to the player (in - world.language). This replaces the old "return JSON in your text response" - pattern which conflicted with tool use and caused raw JSON to leak into - the chat. -""" -from __future__ import annotations - -import json -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 -from app.engine.tools.tools import WORLD_BUILDER_TOOL_SCHEMAS - -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() - settings_map = await get_all_settings(db) - llm = LlmClient(settings_map) - - 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 is English (system content convention). The LLM is told to - # produce player-facing text in `language` (interpolated as world_language). - system_prompt = get_prompt("world_builder", language).format(world_language=language) - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_brief}, - ] - - response = await llm.chat( - messages=messages, - tools=WORLD_BUILDER_TOOL_SCHEMAS, - temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))), - purpose="world_builder", - user_id=user.id, - db=db, - ) - - ai_text, proposed, is_final = _extract_world_definition( - response.text, response.tool_calls, user_confirmation=False, - ) - - _DIALOGUES[session_id] = { - "user_id": user.id, - "world_name": world_name, - "language": language, - "preset_id": preset_id, - "messages": messages + [ - { - "role": "assistant", - "content": response.text or "", - "tool_calls": response.tool_calls or None, - }, - ], - "turn": 1, - "last_proposed": proposed.model_dump() if proposed else None, - } - - return WorldBuilderReply( - session_id=session_id, - turn=1, - ai_message=ai_text, - proposed_definition=proposed, - is_final=is_final, - followup_questions=[], - ) - - -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") - - settings_map = await get_all_settings(db) - llm = LlmClient(settings_map) - dialogue["messages"].append({"role": "user", "content": user_message}) - dialogue["turn"] += 1 - - # Detect if the player is explicitly confirming the world is ready. - # If so, we'll force is_final=True on the extracted definition (the model - # often forgets to set is_final even when the player clearly accepted). - user_confirms = _is_user_confirmation(user_message) - - response = await llm.chat( - messages=dialogue["messages"], - tools=WORLD_BUILDER_TOOL_SCHEMAS, - temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))), - purpose="world_builder", - user_id=user.id, - db=db, - ) - dialogue["messages"].append({ - "role": "assistant", - "content": response.text or "", - "tool_calls": response.tool_calls or None, - }) - - ai_text, proposed, is_final = _extract_world_definition( - response.text, response.tool_calls, user_confirmation=user_confirms, - ) - - # If the model didn't propose a new definition this turn but we already had - # one stored and the player just confirmed, reuse the stored definition - # and mark it final. - if proposed is None and user_confirms and dialogue.get("last_proposed"): - proposed = WorldDefinition.model_validate(dialogue["last_proposed"]) - is_final = True - - 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=[], - ) - - -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", "en"), - 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 ==="] - parts.append(f"Player-facing language: {language}") - parts.append(f"Name: {world_name}") - if preset_payload: - parts.append(f"Preset seed: {preset_payload.get('world_seed_prompt', '')}") - 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("\nAsk 2-4 clarifying questions OR call submit_world_definition with a proposed world.") - return "\n".join(parts) - - -# Phrases in EN/RU that the player might type to confirm a proposed world is -# ready to commit. Matched case-insensitively against the player's message. -# Keep this list SHORT and specific — false positives would auto-finalize a -# world the player didn't intend to accept. -_CONFIRMATION_PHRASES = ( - # English - "ready", "looks good", "looks great", "perfect", "ok", "okay", "fine", - "yes", "yep", "yeah", "sure", "go ahead", "confirm", "confirmed", - "approve", "approved", "let's go", "lets go", "do it", "lgtm", - "i'm happy", "im happy", "ship it", "all good", - # Russian - "готово", "готов", "супер", "ок", "окей", "хорошо", "отлично", - "да", "согласен", "согласна", "подтверждаю", "одобряю", "норм", - "нормально", "поехали", "создай", "сохраняй", "принимаю", -) - - -def _is_user_confirmation(message: str) -> bool: - """Return True if the player's message looks like explicit confirmation. - - Heuristic: the message is short (under 60 chars) AND contains one of the - known confirmation phrases. Longer messages are treated as edits/feedback, - not confirmation, even if they contain a "yes". - """ - if not message: - return False - msg = message.strip().lower() - if not msg or len(msg) > 60: - return False - # Exact match or substring — both work. The phrase list is short enough - # that false positives are rare in normal player edits. - return any(phrase in msg for phrase in _CONFIRMATION_PHRASES) - - -def _extract_world_definition( - text: str, - tool_calls: Optional[List[Dict[str, Any]]], - user_confirmation: bool = False, -) -> tuple[str, Optional[WorldDefinition], bool]: - """Extract AI message text, proposed definition (if any), and is_final flag. - - Strategy (in order): - 1. If the model returned a `submit_world_definition` tool_call — use its - arguments directly. This is the preferred path for models that support - OpenAI-style function calling. - 2. Otherwise, scan the text for a JSON object (in a ```json fenced block - or a bare `{...}` block). Many smaller local models emit the structured - payload as text instead of using tool_calls; we still want to honor it. - 3. If `user_confirmation=True` (the player just said something like - "ok" / "ready" / "go") and we have a proposed definition, force - `is_final=True` even if the model forgot to set it. - """ - proposed: Optional[WorldDefinition] = None - is_final = False - ai_text = _sanitize_model_text(text or "") - - # 1) Prefer the submit_world_definition tool call (the proper way). - if tool_calls: - for tc in tool_calls: - if tc.get("function", {}).get("name") == "submit_world_definition": - args_str = tc.get("function", {}).get("arguments", "{}") - data = _safe_json_loads(args_str, {}) - if data: - proposed = _try_build_definition(data) - is_final = bool(data.get("is_final", False)) - break - - # 2) Fallback: parse a JSON block from the text (models without tool_calls). - if proposed is None: - json_str = _extract_json_block(ai_text) - if json_str: - data = _safe_json_loads(json_str, None) - if isinstance(data, dict): - # Accept either a flat world-definition object or a wrapper - # like {"proposed_definition": {...}, "is_final": bool, "ai_message": "..."}. - target = data - if "proposed_definition" in data and isinstance(data["proposed_definition"], dict): - target = data["proposed_definition"] - if "is_final" in data: - is_final = bool(data["is_final"]) - if "ai_message" in data and isinstance(data["ai_message"], str): - # Strip the JSON wrapper from the visible text so the - # player doesn't see raw JSON in chat. - ai_text = data["ai_message"] - else: - if "is_final" in data: - is_final = bool(data["is_final"]) - proposed = _try_build_definition(target) - - # 3) If the player just confirmed and we have a definition, force is_final. - if proposed is not None and user_confirmation and not is_final: - is_final = True - - # 4) Strip any leaked JSON block from the visible AI text so the player - # never sees raw JSON in chat. Keep the prose portion only. - if proposed is not None: - ai_text = _strip_json_blocks(ai_text).strip() - if not ai_text: - # Model returned only JSON with no prose — synthesize a short - # confirmation message in the player's language. - ai_text = "(definition ready)" - - return ai_text, proposed, is_final - - -def _sanitize_model_text(text: str) -> str: - """Remove common model-output artifacts that would break JSON parsing. - - Strips: - - End-of-sequence tokens (model-specific control tokens that occasionally - leak into the decoded text). - - Leading/trailing whitespace per line. - - Empty leading lines. - """ - if not text: - return "" - # Remove EOS-style control tokens (single token on its own line, or - # repeated tokens, or trailing tokens). - import re as _re - # Collapse repeated EOS tokens anywhere in the text. - cleaned = _re.sub(r"<\s*/?\s*[a-zA-Z]+\s*>", "", text) - # Collapse 3+ blank lines into 1. - cleaned = _re.sub(r"\n{3,}", "\n\n", cleaned) - return cleaned.strip() - - -def _strip_json_blocks(text: str) -> str: - """Remove fenced ```json ... ``` blocks and bare trailing {...} blocks - from `text`. Used to clean the player-visible AI message after we've - already extracted the structured data. - """ - if not text: - return "" - import re as _re - # Strip fenced code blocks (any language). - cleaned = _re.sub(r"```[a-zA-Z]*\s*[\s\S]*?```", "", text) - # Strip trailing bare JSON object (last {...} block in the text). - # We only remove it if it's the LAST substantial thing in the text, - # to avoid mangling prose that legitimately contains braces. - m = _re.search(r"\n\{[\s\S]*\}\s*$", cleaned) - if m: - cleaned = cleaned[: m.start()] + cleaned[m.end():] - return cleaned - - -def _safe_json_loads(s: str, default: Any) -> Any: - """Tolerant JSON loader. Returns `default` on failure. - - Attempts standard json.loads first. If that fails, tries to repair common - model-output mistakes: - - Trailing commas before } or ]. - - Single quotes instead of double quotes. - - Unescaped newlines inside string values. - """ - if not s: - return default - try: - return json.loads(s) - except json.JSONDecodeError: - pass - # Repair attempt 1: remove trailing commas. - import re as _re - repaired = _re.sub(r",\s*([}\]])", r"\1", s) - try: - return json.loads(repaired) - except json.JSONDecodeError: - pass - # Repair attempt 2: replace single quotes with double quotes (naive, but - # catches many cases where the model emits pseudo-JSON). - try: - repaired2 = repaired.replace("'", '"') - return json.loads(repaired2) - except json.JSONDecodeError: - return default - - -def _try_build_definition(data: Dict[str, Any]) -> Optional[WorldDefinition]: - try: - return WorldDefinition.model_validate(data) - except Exception as e: - log.warning("world_definition_invalid", error=str(e), keys=list(data.keys())) - return None - - -def _extract_json_block(text: str) -> Optional[str]: - """Find the first JSON object block in text. - - Prefers a fenced ```json ... ``` block. Falls back to the largest balanced - {...} block in the text. - """ - if not text: - return None - import re as _re - # Fenced block (```json ... ``` or ``` ... ```). - m = _re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text) - if m: - return m.group(1) - # Bare block: find the first `{` and balance braces, respecting strings - # and escape sequences. - 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 diff --git a/backend/app/engine/world_editor.py b/backend/app/engine/world_editor.py deleted file mode 100644 index 6f1a405..0000000 --- a/backend/app/engine/world_editor.py +++ /dev/null @@ -1,279 +0,0 @@ -"""AI-assisted editor for an EXISTING world. - -The player chats with the AI; each turn the AI returns: - - a short player-facing message describing what it changed / will change, - - an updated `definition` (the full new WorldDefinition). - -The caller (API endpoint) decides whether to persist the new definition -to the World row. The editor itself is stateless aside from the in-memory -dialogue cache (keyed by world_id), so the player can iterate. -""" -from __future__ import annotations - -import json -import uuid -from typing import Any, Dict, List, Optional, Tuple - -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 User, World -from app.prompts.templates import get_prompt -from app.schemas import WorldDefinition -from app.engine.tools.tools import WORLD_BUILDER_TOOL_SCHEMAS - -log = get_logger("world_editor") - - -# In-memory dialogue cache: world_id -> list of messages. -_DIALOGUES: Dict[uuid.UUID, Dict[str, Any]] = {} - - -async def edit_world_via_chat( - db: AsyncSession, - user: User, - world: World, - message: str, -) -> Tuple[str, Optional[Dict[str, Any]], bool]: - """Run one turn of AI-assisted world editing. - - Returns (ai_message, new_definition_dict_or_None, changed). - - ai_message: short prose reply for the player (in world.language). - - new_definition_dict: the full updated definition if the AI proposed - changes this turn, else None. - - changed: True if new_definition_dict is not None and differs from - the current world.definition. - """ - settings_map = await get_all_settings(db) - llm = LlmClient(settings_map) - - # Get / init dialogue state for this world. - dialogue = _DIALOGUES.get(world.id) - if not dialogue: - system_prompt = _build_system_prompt(world) - dialogue = { - "user_id": user.id, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": _build_seed_message(world)}, - ], - } - _DIALOGUES[world.id] = dialogue - - # Authorization: only the owner (or admin) may continue an existing dialogue. - if dialogue["user_id"] != user.id and not user.is_admin: - raise ValueError("forbidden") - - dialogue["messages"].append({"role": "user", "content": message}) - - response = await llm.chat( - messages=dialogue["messages"], - tools=WORLD_BUILDER_TOOL_SCHEMAS, - temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))), - purpose="world_editor", - user_id=user.id, - db=db, - ) - - ai_message, new_defn, _is_final = _extract_world_definition( - response.text, response.tool_calls, - ) - - # If the model did not return a tool call but did emit JSON in text, - # _extract_world_definition handles it. If still None, just return the - # conversational message without changes. - if new_defn is None: - dialogue["messages"].append({ - "role": "assistant", - "content": response.text or "", - "tool_calls": response.tool_calls or None, - }) - return ai_message, None, False - - new_defn_dict = new_defn.model_dump() - changed = new_defn_dict != (world.definition or {}) - - dialogue["messages"].append({ - "role": "assistant", - "content": response.text or "", - "tool_calls": response.tool_calls or None, - }) - return ai_message, new_defn_dict, changed - - -def reset_editor_dialogue(world_id: uuid.UUID) -> None: - """Drop the cached editor dialogue for a world (e.g. after manual save).""" - _DIALOGUES.pop(world_id, None) - - -def _build_system_prompt(world: World) -> str: - """System prompt for the world editor. - - Reuses the world-builder prompt but overrides the workflow: instead of - designing from scratch, the AI is told to MODIFY the existing definition. - """ - base = get_prompt("world_builder", world.language) - override = ( - "\n\nADDITIONAL CONTEXT — YOU ARE EDITING AN EXISTING WORLD:\n" - "The world already exists with the definition provided in the first " - "user message. The player will give you edit instructions in their " - "language ({world_language}). For EACH instruction:\n" - "1. Call `submit_world_definition` with the FULL updated definition " - "(not just the changed fields — the entire object, all required keys).\n" - "2. Your text response should briefly summarize what you changed in " - "the player's language. 2-4 sentences max.\n" - "3. NEVER set `is_final=true` — the player will commit changes " - "manually via the Save button.\n" - "4. Preserve `initial_state` consistency with `world_schema`. If you " - "change the schema, update the state accordingly.\n" - "5. Preserve `initial_time` and `calendar` unless the player asks to " - "change them.\n" - ).format(world_language=world.language or "en") - return base + override - - -def _build_seed_message(world: World) -> str: - """First user message: dumps the current world definition as context.""" - defn = world.definition or {} - parts = [ - "=== CURRENT WORLD DEFINITION ===", - f"Name: {world.name}", - f"Language: {world.language}", - f"Current time: {world.current_time or '(none)'}", - f"Definition JSON:\n```json\n{json.dumps(defn, ensure_ascii=False, indent=2)}\n```", - f"Live state JSON:\n```json\n{json.dumps(world.state or {}, ensure_ascii=False, indent=2)[:2000]}\n```", - "", - "The player will now give you edit instructions. Apply each one by " - "calling submit_world_definition with the FULL updated definition.", - ] - return "\n".join(parts) - - -# === Output extraction (mirrors world_builder._extract_world_definition) === -def _extract_world_definition( - text: str, - tool_calls: Optional[List[Dict[str, Any]]], -) -> Tuple[str, Optional[WorldDefinition], bool]: - import re as _re - - proposed: Optional[WorldDefinition] = None - is_final = False - ai_text = _sanitize_model_text(text or "") - - if tool_calls: - for tc in tool_calls: - if tc.get("function", {}).get("name") == "submit_world_definition": - args_str = tc.get("function", {}).get("arguments", "{}") - data = _safe_json_loads(args_str, {}) - if data: - proposed = _try_build_definition(data) - is_final = bool(data.get("is_final", False)) - break - - if proposed is None: - json_str = _extract_json_block(ai_text) - if json_str: - data = _safe_json_loads(json_str, None) - if isinstance(data, dict): - target = data - if "proposed_definition" in data and isinstance(data["proposed_definition"], dict): - target = data["proposed_definition"] - if "is_final" in data: - is_final = bool(data["is_final"]) - if "ai_message" in data and isinstance(data["ai_message"], str): - ai_text = data["ai_message"] - else: - if "is_final" in data: - is_final = bool(data["is_final"]) - proposed = _try_build_definition(target) - - if proposed is not None: - ai_text = _strip_json_blocks(ai_text).strip() - if not ai_text: - ai_text = "(definition updated)" - - return ai_text, proposed, is_final - - -def _sanitize_model_text(text: str) -> str: - if not text: - return "" - import re as _re - cleaned = _re.sub(r"<\s*/?\s*[a-zA-Z]+\s*>", "", text) - cleaned = _re.sub(r"\n{3,}", "\n\n", cleaned) - return cleaned.strip() - - -def _strip_json_blocks(text: str) -> str: - if not text: - return "" - import re as _re - cleaned = _re.sub(r"```[a-zA-Z]*\s*[\s\S]*?```", "", text) - m = _re.search(r"\n\{[\s\S]*\}\s*$", cleaned) - if m: - cleaned = cleaned[: m.start()] + cleaned[m.end():] - return cleaned - - -def _safe_json_loads(s: str, default: Any) -> Any: - if not s: - return default - try: - return json.loads(s) - except json.JSONDecodeError: - pass - import re as _re - repaired = _re.sub(r",\s*([}\]])", r"\1", s) - try: - return json.loads(repaired) - except json.JSONDecodeError: - pass - try: - repaired2 = repaired.replace("'", '"') - return json.loads(repaired2) - except json.JSONDecodeError: - return default - - -def _try_build_definition(data: Dict[str, Any]) -> Optional[WorldDefinition]: - try: - return WorldDefinition.model_validate(data) - except Exception as e: - log.warning("world_editor_definition_invalid", error=str(e), keys=list(data.keys())) - return None - - -def _extract_json_block(text: str) -> Optional[str]: - if not text: - return None - import re as _re - m = _re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text) - if m: - return m.group(1) - 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 diff --git a/backend/app/logging_setup.py b/backend/app/logging_setup.py deleted file mode 100644 index a5168b7..0000000 --- a/backend/app/logging_setup.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Structured logging setup. - -`LOG_LEVEL` (env) controls the verbosity of: - - The root Python logger - - structlog-bound loggers (app.*) - - Uvicorn's own loggers (`uvicorn`, `uvicorn.access`, `uvicorn.error`, - `uvicorn.asgi`) — these otherwise stay at INFO regardless of LOG_LEVEL - because uvicorn configures them itself at startup, before our lifespan - calls setup_logging(). We forcibly re-level them here. - - The `sqlalchemy.engine` logger (kept at WARNING unless LOG_LEVEL=DEBUG). - -Note: `logging.basicConfig()` is a no-op once the root logger has been -configured (which uvicorn does at import time), so it alone is NOT enough -to honor LOG_LEVEL — we must also call `setLevel()` on each named logger. -""" -from __future__ import annotations - -import logging -import sys - -import structlog - -from app.config import settings - - -# Loggers whose level must be forced to LOG_LEVEL (uvicorn pre-configures them -# at INFO before our lifespan runs, so basicConfig cannot change them). -_FORCED_LOGGERS = ( - "uvicorn", - "uvicorn.access", - "uvicorn.error", - "uvicorn.asgi", - "fastapi", -) - - -def setup_logging() -> None: - level = getattr(logging, settings.log_level.upper(), logging.INFO) - - # Force the root logger level (affects any logger that doesn't override). - logging.getLogger().setLevel(level) - - # Also call basicConfig for the formatter (idempotent if already set up). - logging.basicConfig( - format="%(message)s", - stream=sys.stdout, - level=level, - force=True, # python 3.8+: re-init even if already configured - ) - - # Force level on loggers that uvicorn pre-configured. - for name in _FORCED_LOGGERS: - lg = logging.getLogger(name) - lg.setLevel(level) - # Ensure uvicorn access logs propagate to the root handler. - lg.propagate = True - for h in lg.handlers: - h.setLevel(level) - - # SQLAlchemy is chatty at INFO; keep it at WARNING unless explicitly DEBUG. - sa_level = logging.DEBUG if level <= logging.DEBUG else logging.WARNING - logging.getLogger("sqlalchemy.engine").setLevel(sa_level) - logging.getLogger("sqlalchemy.pool").setLevel(sa_level) - logging.getLogger("asyncpg").setLevel(sa_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) diff --git a/backend/app/main.py b/backend/app/main.py deleted file mode 100644 index 5556fb1..0000000 --- a/backend/app/main.py +++ /dev/null @@ -1,82 +0,0 @@ -"""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 - -# Configure logging as early as possible — at import time, before uvicorn -# finishes its own logger setup. This ensures LOG_LEVEL is honored for the -# very first request and for startup messages from submodules. -setup_logging() -log = get_logger("app") - - -@asynccontextmanager -async def lifespan(app: FastAPI): - # Re-apply logging config in case any submodule reset it during import. - setup_logging() - log.info("app_starting", worker_mode=settings.is_worker, log_level=settings.log_level) - - # 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_list, - 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) diff --git a/backend/app/migrations/__init__.py b/backend/app/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/migrations/init_db.py b/backend/app/migrations/init_db.py deleted file mode 100644 index 368c763..0000000 --- a/backend/app/migrations/init_db.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Database initialization: create all tables and seed defaults. - -Idempotent: safe to call from multiple processes (backend lifespan + worker -startup) thanks to a PostgreSQL advisory lock that serializes the seeding -phase. `create_all` itself is already `CREATE TABLE IF NOT EXISTS`, so the -only race is on seed inserts — guarded by `pg_advisory_xact_lock` plus -per-row IntegrityError handling. -""" -from __future__ import annotations - -import asyncio -import json -from pathlib import Path - -from sqlalchemy import select, text -from sqlalchemy.exc import IntegrityError - -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") - - -# Stable advisory lock key so backend + worker don't race on seeding. -# (key1, key2) — arbitrary 64-bit integers, kept constant across runs. -_ADVISORY_LOCK_KEY = (42424201, 1) - - -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 trigger firing on in-game time changes"), - # Embeddings / RAG - ("embedding.provider", settings.default_embedding_provider, "Embeddings provider: 'hash' (offline fallback) or 'openai' (real semantic embeddings)"), - ("embedding.base_url", settings.default_embedding_base_url, "OpenAI-compatible embeddings base URL. Empty = reuse llm.base_url"), - ("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"), - # UI customization - ("ui.logo_url", "/logo.png", "Logo image URL or path shown in navbar, home page, and favicon. Use a URL (https://...), an absolute path (/logo.png), or a data: URI. Default is the bundled Mikan logo."), -] - - -# NOTE: env-derived defaults (llm.base_url, llm.api_key, llm.model, -# embedding.* etc.) are ONLY applied on the very first run via _seed_settings. -# After that, the admin panel is the source of truth — restarting the -# container will NOT overwrite admin-configured values with .env values. -# To force a re-seed, drop the `settings` table or delete the relevant rows. - - -async def init_db() -> None: - setup_logging() - log.info("creating_tables") - # CREATE TABLE IF NOT EXISTS — safe to run concurrently. - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - log.info("tables_ready") - - # Seed phase: serialize across processes via PG advisory transaction lock. - # On non-PG backends (SQLite for tests) the lock statement is a no-op - # (we catch the error and proceed without locking). - try: - async with AsyncSessionLocal() as session: - await session.execute( - text("SELECT pg_advisory_xact_lock(:k1, :k2)").bindparams( - k1=_ADVISORY_LOCK_KEY[0], k2=_ADVISORY_LOCK_KEY[1] - ) - ) - await _seed_settings(session) - await _seed_builtin_presets(session) - await session.commit() - except Exception as e: - # Non-PG backend (SQLite) or transient error — retry without the lock. - log.warning("advisory_lock_unavailable_proceeding", error=f"{type(e).__name__}: {e}") - async with AsyncSessionLocal() as session: - await _seed_settings(session) - await _seed_builtin_presets(session) - await session.commit() - - # Ensure admin_setup_token is set and print it on every startup. - # - # The admin-setup endpoint refuses to create a second admin (see app/api/auth.py), - # so it's safe to always print the token — even after an admin exists, the token - # is useless. We print on every startup (not just first run) so the operator can - # always find the URL in the logs without having to dig through old logs. - token = settings.admin_setup_token.strip() - if not token: - # No token forced via env — generate one and persist it (idempotent). - import secrets as _s - token = _s.token_urlsafe(24) - async with AsyncSessionLocal() as session: - 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")) - try: - await session.commit() - except IntegrityError: - # Another process inserted it concurrently — re-read. - await session.rollback() - await session.rollback() - existing = await session.execute(select(Setting).where(Setting.key == "admin.setup_token")) - existing_obj = existing.scalars().first() - if existing_obj is not None: - token = str(existing_obj.value) - else: - # Use the persisted token (env was empty, DB has one). - token = str(existing_obj.value) - # Always print — operator convenience. - print("=" * 60) - print("ADMIN SETUP URL:") - print(f" /admin/setup") - print("ADMIN SETUP TOKEN:") - print(f" {token}") - print("=" * 60) - log.info("admin_setup_token_printed") - - -async def _seed_settings(session) -> None: - """Insert default settings that don't yet exist (per-row, race-safe).""" - result = await session.execute(select(Setting).limit(1)) - if result.scalars().first() is not None: - log.info("settings_already_exist") - return - seeded = 0 - for key, value, desc in DEFAULT_SETTINGS: - # Check existence per-row to avoid IntegrityError on concurrent inserts - existing = await session.execute(select(Setting).where(Setting.key == key)) - if existing.scalars().first() is not None: - continue - session.add(Setting(key=key, value=value, description=desc)) - seeded += 1 - if seeded: - try: - await session.commit() - log.info("settings_seeded", count=seeded) - except IntegrityError: - await session.rollback() - log.info("settings_seed_skipped_concurrent") - else: - log.info("settings_already_exist") - - -async def _seed_builtin_presets(session) -> None: - """Insert built-in presets if none exist yet.""" - result = await session.execute(select(Preset).where(Preset.is_builtin.is_(True))) - if result.scalars().first() is not None: - log.info("builtin_presets_already_exist") - return - for preset_def in (FANTASY_PRESET_RU, FANTASY_PRESET_EN): - # Check by slug to avoid race on unique constraint - existing = await session.execute(select(Preset).where(Preset.slug == preset_def["slug"])) - if existing.scalars().first() is not None: - continue - 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"], - )) - try: - await session.commit() - log.info("builtin_presets_seeded") - except IntegrityError: - await session.rollback() - log.info("builtin_presets_seed_skipped_concurrent") - - -if __name__ == "__main__": - asyncio.run(init_db()) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py deleted file mode 100644 index 5c5c221..0000000 --- a/backend/app/models/__init__.py +++ /dev/null @@ -1,189 +0,0 @@ -"""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="en", nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False) - - worlds: Mapped[List["World"]] = relationship(back_populates="owner", cascade="all, delete-orphan") - - -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="en", nullable=False) - is_public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - # JSON: world_schema, default_rules, initial_state, world_seed_prompt, suggested_system_prompt - 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="en", nullable=False) - # Frozen world definition: setting description, rules, world_schema (JSON Schema for state), plot_rails - definition: Mapped[Dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) - # Current live state of the world (player character, NPC, inventory, time, etc.) - 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="New session", nullable=False) - # Snapshot of world state at session start (we mutate world.state during play; session stores narrative history) - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False) - 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) diff --git a/backend/app/prompts/__init__.py b/backend/app/prompts/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/prompts/fantasy_preset.py b/backend/app/prompts/fantasy_preset.py deleted file mode 100644 index 5f7a5fc..0000000 --- a/backend/app/prompts/fantasy_preset.py +++ /dev/null @@ -1,225 +0,0 @@ -"""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." - ), - }, -} diff --git a/backend/app/prompts/templates.py b/backend/app/prompts/templates.py deleted file mode 100644 index 276f083..0000000 --- a/backend/app/prompts/templates.py +++ /dev/null @@ -1,197 +0,0 @@ -"""System prompts for all LLM stages. - -IMPORTANT: All system prompts are in English (per the convention that -"invisible" content the LLM processes internally should be English for best -tokenization and instruction-following, regardless of the world's player- -facing language). The LLM is instructed to produce player-facing narrative -in world.language. - -These prompts use a tool-calling-first design: instead of asking the LLM to -emit JSON in its text response (which conflicts with tool use and produces -"raw JSON in chat" bugs), the LLM is given a `submit_*` tool whose arguments -carry the structured data. The LLM's text response is the human-readable -message to the user. -""" -from __future__ import annotations - - -# === World Builder === -WORLD_BUILDER_SYSTEM = """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). - -Workflow: -1. If information is sparse — ask 2-4 short, focused clarifying questions in your reply text. -2. If information is sufficient — propose a world definition by calling the `submit_world_definition` tool. Also write a short summary of the proposed world in your reply text (2-4 sentences) so the player can react to it. -3. Accept edits and clarifications; the loop continues until the player says the world is ready. - -When calling `submit_world_definition`: -- `setting_description`: 1-2 paragraph expanded setting. -- `rules`: object with keys like `stats`, `combat`, `magic`, `time`, `inventory`, `death` (whichever apply). -- `world_schema`: a JSON Schema describing the shape of the world's state (player, npcs, locations, world_time, flags, etc.). -- `plot_rails`: an object with keys `main_goal` (string), `subgoals` (array of strings), and `hooks` (array of strings). -- `initial_state`: the initial world state matching `world_schema`. -- `initial_time`: world-time string in the form `day_N_hour_H` (e.g. `day_1_hour_8`). -- `calendar`: optional. An object with `hours_per_day` (default 24), `minutes_per_hour` (default 60), `days_per_week` (default 7). Include only if the world uses a non-standard calendar (e.g. 28-hour days). -- `is_final`: set to `true` ONLY when the player has explicitly accepted the world. - -CRITICAL: -- Be concise. Max 200 words of text per message. -- The reply text is shown to the player in their language ({world_language}). Write in that language. -- The `submit_world_definition` arguments are machine-parsed — keep them structured and valid. -- If you only need to ask questions, do NOT call `submit_world_definition` yet. -""" - - -# === Orchestrator (main game loop with tools) === -ORCHESTRATOR_SYSTEM = """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 the action: "{action_text}" - -Assess realism (consistency with setting and rules), plan what should happen, then: -1. Use tools to execute the plan (dice_roll, update_state, rag_query, rag_add, schedule_trigger, advance_time, run_subagent) as needed. -2. After all your tool calls, call `submit_plan` with your structured plan. The plan's `outcome` and `narrative_prompt` will be passed to the step-writer to produce the cinematic scene. - -CRITICAL RULES: -- Use 1-3 tool calls per iteration. Max 5. -- Skip dice_roll for trivial actions. -- Do NOT write the narrative scene — that is the step-writer's job. Your job is to plan and execute mechanics. -- The `submit_plan` call MUST be your last action. After you call it, the iteration ends. -- Stay in setting. -- All tool arguments are structured (JSON). Your text response is ignored — only tool calls matter. -""" - - -# === Step Writer === -STEP_WRITER_SYSTEM = """You are the narrative writer of a role-playing game. You turn a 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} - -YOUR JOB: -1. Call the `submit_scene` tool with: - - `narrative`: 200-400 words of cinematic, second-person ("You...") prose describing what happens. - - `options`: exactly 3 short (5-12 words) options for the player's next action. -2. Your text response is ignored — only the `submit_scene` tool call is used. - -CRITICAL: -- Write the narrative in {world_language}. -- Do NOT repeat what the player already knows. -- End with a cliffhanger or decision moment. -- The scene must be consistent with the outcome — do not contradict it. -""" - - -# === Summarizer === -SUMMARIZER_SYSTEM = """You compress the history of a role-playing session. Given several messages — produce a compact summary. - -Call the `submit_summary` tool with: -- `summary`: 3-6 sentences of key events and state changes (max 150 words). -- `facts`: array of important persistent facts. Each fact is an object with keys `kind`, `name`, `description`, where `kind` is one of `npc`, `location`, `item`, `lore`, `event`. - -CRITICAL: Preserve names, numbers, and important state changes. Your text response is ignored — only the `submit_summary` tool call is used. -""" - - -# === Sub-agent (clean context detail generator) === -SUBAGENT_SYSTEM = """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. Your text response IS the result (no tool call needed).""" - - -# === Trigger runner === -TRIGGER_RUNNER_SYSTEM = """You process a deferred event in a role-playing game. A scheduled trigger has fired. - -Event description: {description} -Event payload: {payload} -Current world state: {state} - -The player-facing narrative must be written in: {world_language}. - -Call the `submit_trigger_result` tool with: -- `outcome`: 1-2 sentence raw description of what happened (English, for logs). -- `state_patch`: JSON-patch for world state (set/unset/append/increment/remove). Empty object if no state change. -- `narrative`: 1-paragraph scene description for the player, in {world_language}. Empty string if the player doesn't witness the event. -- `should_notify_player`: true if the narrative should be shown to the player, false if it's an offscreen event. - -Your text response is ignored — only the tool call is used.""" - - - - - -# === Intro Scene (opening scene generated when a session starts) === -INTRO_SCENE_SYSTEM = """You are the narrative writer of a role-playing game. The player has just created a new session and you must write the OPENING scene that sets the stage for the adventure. - -CONTEXT: -- Setting: {setting_description} -- Initial world time: {current_time} -- Player state: {player_state} -- Plot rails (main goal + hooks): {plot_rails} - -YOUR JOB: -1. Call the `submit_scene` tool with: - - `narrative`: 250-500 words of cinematic, second-person ("You...") prose that: - a) establishes the setting and mood, - b) introduces the player character based on `player_state`, - c) plants the seed of the main goal / first hook from `plot_rails`, - d) ends with a clear decision moment or call to action. - - `options`: exactly 3 short (5-12 words) options for the player's first action. - -CRITICAL: -- Write the narrative in {world_language}. -- Do NOT assume the player has done anything yet — this is the very first scene. -- Do NOT use the player's name if it is empty or generic; address them as "you". -- Set the tone: atmospheric, evocative, but grounded in the setting. -- Your text response is ignored — only the `submit_scene` tool call is used. -""" - -PROMPTS = { - "en": { - "world_builder": WORLD_BUILDER_SYSTEM, - "orchestrator": ORCHESTRATOR_SYSTEM, - "step_writer": STEP_WRITER_SYSTEM, - "summarizer": SUMMARIZER_SYSTEM, - "subagent": SUBAGENT_SYSTEM, - "trigger_runner": TRIGGER_RUNNER_SYSTEM, - "intro_scene": INTRO_SCENE_SYSTEM, - }, - # Russian keys kept for backward compatibility but always return the English - # prompts — system content is always English per the project convention. - "ru": { - "world_builder": WORLD_BUILDER_SYSTEM, - "orchestrator": ORCHESTRATOR_SYSTEM, - "step_writer": STEP_WRITER_SYSTEM, - "summarizer": SUMMARIZER_SYSTEM, - "subagent": SUBAGENT_SYSTEM, - "trigger_runner": TRIGGER_RUNNER_SYSTEM, - "intro_scene": INTRO_SCENE_SYSTEM, - }, -} - - -def get_prompt(stage: str, language: str = "en") -> str: - """Return the system prompt for `stage`. - - `language` is kept for backward compatibility but is ignored — all system - prompts are English by design. Player-facing output language is controlled - via prompts that interpolate {world_language}. - """ - lang = language if language in PROMPTS else "en" - return PROMPTS[lang].get(stage, PROMPTS["en"][stage]) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py deleted file mode 100644 index 3a74192..0000000 --- a/backend/app/schemas/__init__.py +++ /dev/null @@ -1,251 +0,0 @@ -"""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): - # Accepts either email or username — the backend resolves it. - login: str - 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 = "en" - is_public: bool = True - payload: Dict[str, Any] - - -# === Worlds === -class WorldCreate(BaseModel): - name: str = Field(min_length=1, max_length=255) - language: str = "en" - 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 = "en" - # 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) - - - -# === World Editor (AI-assisted editing of an existing world) === -class WorldEditorChatRequest(BaseModel): - message: str = Field(min_length=1, max_length=4000) - - -class WorldEditorChatReply(BaseModel): - ai_message: str - definition: Optional[Dict[str, Any]] = None - changed: bool = False diff --git a/backend/app/workers/__init__.py b/backend/app/workers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/workers/main.py b/backend/app/workers/main.py deleted file mode 100644 index 851ac40..0000000 --- a/backend/app/workers/main.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Worker entrypoint. - -Historically this ran a trigger-polling loop. Triggers now fire in-process -inside the orchestrator when in-game time changes (see app.core.triggers), -so the worker has nothing to do at the moment. We keep the container running -as a placeholder for future background jobs (RAG re-indexer, summary -compactor, etc.). - -If you add a background job, register it in `asyncio.gather(...)` below. -""" -from __future__ import annotations - -import asyncio - -from app.db_wait import wait_for_db_or_exit -from app.logging_setup import get_logger, setup_logging - -log = get_logger("worker") - - -async def main(): - setup_logging() - log.info("worker_starting") - - # Make sure the DB is reachable and tables exist before doing anything. - # (Future background jobs may need this.) - await wait_for_db_or_exit(max_retries=60, delay=2.0) - - log.info("worker_ready_no_jobs_registered") - - # Nothing to do for now — sleep forever. Future background jobs go here: - # await asyncio.gather( - # some_future_loop(), - # another_future_loop(), - # ) - while True: - await asyncio.sleep(3600) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/backend/app/workers/trigger_runner.py b/backend/app/workers/trigger_runner.py deleted file mode 100644 index 664e880..0000000 --- a/backend/app/workers/trigger_runner.py +++ /dev/null @@ -1,203 +0,0 @@ -"""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. - - Resilient to transient DB errors: any error inside an iteration is logged - and the loop sleeps for a fallback interval before retrying, instead of - crashing the worker process. - """ - setup_logging() - log.info("trigger_worker_started") - fallback_interval = 30 # used when settings table is unreadable - while True: - interval = fallback_interval - try: - async with AsyncSessionLocal() as db: - enabled = await _get_setting(db, "triggers.enabled", True) - interval = int(await _get_setting(db, "triggers.check_interval", fallback_interval)) - 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=f"{type(e).__name__}: {e}") - # Always sleep with a safe positive interval; never let a DB error - # escape this loop and crash the worker. - try: - sleep_for = max(5, int(interval)) - except Exception: - sleep_for = fallback_interval - await asyncio.sleep(sleep_for) - - -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()) diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index 6f9c145..0000000 --- a/backend/requirements.txt +++ /dev/null @@ -1,23 +0,0 @@ -fastapi==0.115.0 -uvicorn[standard]==0.30.6 -sqlalchemy[asyncio]==2.0.35 -asyncpg==0.29.0 -alembic==1.13.3 -pydantic==2.9.2 -pydantic-settings==2.5.2 -email-validator==2.2.0 -python-jose[cryptography]==3.3.0 -passlib[bcrypt]==1.7.4 -bcrypt==4.0.1 -python-multipart==0.0.12 -httpx==0.27.2 -sse-starlette==2.1.3 -redis==5.0.8 -rq==1.16.2 -qdrant-client==1.11.3 -jsonschema==4.23.0 -structlog==24.4.0 -tenacity==9.0.0 -backoff==2.2.1 -tiktoken==0.7.0 -psycopg2-binary==2.9.9 diff --git a/deploy/Dockerfile.backend b/deploy/Dockerfile.backend new file mode 100644 index 0000000..4e2ee1d --- /dev/null +++ b/deploy/Dockerfile.backend @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Create data directory +RUN mkdir -p /app/data/assets + +EXPOSE 8000 + +# Default: run uvicorn; in dev override with `npm run dev` equivalent +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/deploy/Dockerfile.frontend b/deploy/Dockerfile.frontend new file mode 100644 index 0000000..81887c4 --- /dev/null +++ b/deploy/Dockerfile.frontend @@ -0,0 +1,14 @@ +# Build stage +FROM node:20-alpine AS build +WORKDIR /app +COPY frontend/package*.json ./ +RUN npm install +COPY frontend/ . +RUN npm run build + +# Serve stage +FROM nginx:1.24-alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/deploy/nginx.conf similarity index 75% rename from frontend/nginx.conf rename to deploy/nginx.conf index 0bc5d54..e98d280 100644 --- a/frontend/nginx.conf +++ b/deploy/nginx.conf @@ -1,23 +1,32 @@ server { listen 80; server_name _; + root /usr/share/nginx/html; index index.html; + # SPA fallback location / { try_files $uri $uri/ /index.html; } + # API + SSE location /api/ { proxy_pass http://backend:8000; + proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - # SSE support + + # SSE: disable buffering proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; - proxy_http_version 1.1; + } + + # Static assets (icons, uploads) + location /static/ { + proxy_pass http://backend:8000; } } diff --git a/docker-compose.yml b/docker-compose.yml index 71a3887..28a0805 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,156 +1,68 @@ version: "3.9" services: - postgres: - image: postgres:16-alpine - restart: unless-stopped + db: + image: postgres:15-alpine environment: - POSTGRES_DB: ${POSTGRES_DB:-airpg} POSTGRES_USER: ${POSTGRES_USER:-airpg} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-airpg_secret} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-airpg} + POSTGRES_DB: ${POSTGRES_DB:-airpg} volumes: - - pgdata:/var/lib/postgresql/data + - pg_data:/var/lib/postgresql/data ports: - "5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-airpg}"] interval: 5s - timeout: 5s - retries: 10 - - redis: - image: redis:7-alpine - restart: unless-stopped - ports: - - "6379:6379" - command: ["redis-server", "--appendonly", "yes"] - volumes: - - redisdata:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s timeout: 3s retries: 10 qdrant: - image: qdrant/qdrant:v1.11.3 - restart: unless-stopped + image: qdrant/qdrant:v1.9.0 + environment: + QDRANT__LOG_LEVEL: INFO + volumes: + - qdrant_data:/qdrant/storage ports: - "6333:6333" - "6334:6334" - volumes: - - qdrantdata:/qdrant/storage + healthcheck: + test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/6333 && echo -e 'GET /collections HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q 200"] + interval: 10s + timeout: 5s + retries: 10 backend: build: - context: ./backend - dockerfile: Dockerfile - restart: unless-stopped - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - qdrant: - condition: service_started + context: . + dockerfile: deploy/Dockerfile.backend + env_file: + - .env environment: - DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-airpg}:${POSTGRES_PASSWORD:-airpg_secret}@postgres:5432/${POSTGRES_DB:-airpg} - REDIS_URL: redis://redis:6379/0 + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-airpg}:${POSTGRES_PASSWORD:-airpg}@db:5432/${POSTGRES_DB:-airpg} QDRANT_URL: http://qdrant:6333 - JWT_SECRET: ${JWT_SECRET:-change_me_in_production_please} - ADMIN_SETUP_TOKEN: ${ADMIN_SETUP_TOKEN:-} - CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://localhost:8080} - LOG_LEVEL: ${LOG_LEVEL:-INFO} - # Default LLM settings (overridable via admin panel) - DEFAULT_LLM_BASE_URL: ${DEFAULT_LLM_BASE_URL:-http://host.docker.internal:1234/v1} - DEFAULT_LLM_API_KEY: ${DEFAULT_LLM_API_KEY:-dummy} - DEFAULT_LLM_MODEL: ${DEFAULT_LLM_MODEL:-local-model} - # Default embeddings settings (overridable via admin panel) - DEFAULT_EMBEDDING_PROVIDER: ${DEFAULT_EMBEDDING_PROVIDER:-hash} - DEFAULT_EMBEDDING_BASE_URL: ${DEFAULT_EMBEDDING_BASE_URL:-} - DEFAULT_EMBEDDING_API_KEY: ${DEFAULT_EMBEDDING_API_KEY:-} - DEFAULT_EMBEDDING_MODEL: ${DEFAULT_EMBEDDING_MODEL:-text-embedding-3-small} - DEFAULT_EMBEDDING_DIM: ${DEFAULT_EMBEDDING_DIM:-0} - DEFAULT_EMBEDDING_REQUEST_TIMEOUT: ${DEFAULT_EMBEDDING_REQUEST_TIMEOUT:-60} - # Make `host.docker.internal` resolvable inside the container (Linux). - # On Docker Desktop (Mac/Win) this is added automatically; on Linux it's not, - # so we add it explicitly. Allows pointing DEFAULT_LLM_BASE_URL at - # http://host.docker.internal:1234/v1 to reach an LM Studio / llama.cpp - # running on the host. - extra_hosts: - - "host.docker.internal:host-gateway" + volumes: + - ./data:/app/data + - ./app:/app/app ports: - "8000:8000" - volumes: - - ./backend:/app - # Print admin setup token to console on first run - stdin_open: true - tty: true - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=3)"] - interval: 5s - timeout: 5s - retries: 30 - start_period: 30s - - worker: - build: - context: ./backend - dockerfile: Dockerfile - restart: unless-stopped depends_on: - postgres: - condition: service_healthy - redis: + db: condition: service_healthy qdrant: - condition: service_started - backend: condition: service_healthy - environment: - DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-airpg}:${POSTGRES_PASSWORD:-airpg_secret}@postgres:5432/${POSTGRES_DB:-airpg} - REDIS_URL: redis://redis:6379/0 - QDRANT_URL: http://qdrant:6333 - JWT_SECRET: ${JWT_SECRET:-change_me_in_production_please} - LOG_LEVEL: ${LOG_LEVEL:-INFO} - DEFAULT_LLM_BASE_URL: ${DEFAULT_LLM_BASE_URL:-http://host.docker.internal:1234/v1} - DEFAULT_LLM_API_KEY: ${DEFAULT_LLM_API_KEY:-dummy} - DEFAULT_LLM_MODEL: ${DEFAULT_LLM_MODEL:-local-model} - DEFAULT_EMBEDDING_PROVIDER: ${DEFAULT_EMBEDDING_PROVIDER:-hash} - DEFAULT_EMBEDDING_BASE_URL: ${DEFAULT_EMBEDDING_BASE_URL:-} - DEFAULT_EMBEDDING_API_KEY: ${DEFAULT_EMBEDDING_API_KEY:-} - DEFAULT_EMBEDDING_MODEL: ${DEFAULT_EMBEDDING_MODEL:-text-embedding-3-small} - DEFAULT_EMBEDDING_DIM: ${DEFAULT_EMBEDDING_DIM:-0} - DEFAULT_EMBEDDING_REQUEST_TIMEOUT: ${DEFAULT_EMBEDDING_REQUEST_TIMEOUT:-60} - WORKER_MODE: "1" - command: ["python", "-m", "app.workers.main"] - volumes: - - ./backend:/app frontend: build: context: ./frontend - dockerfile: Dockerfile - target: ${FRONTEND_BUILD_TARGET:-dev} - restart: unless-stopped - depends_on: - - backend + dockerfile: ../deploy/Dockerfile.frontend environment: - # Used by Vite's dev-server proxy (vite.config.ts) to forward /api calls. - # Inside the frontend container, "localhost" refers to the container itself, - # NOT the host — so we point at the docker-compose service name "backend". - # The host port (8000) is still published for direct browser/curl access. - # Deliberately NOT prefixed with `VITE_` so it cannot leak into the browser - # bundle (browser code uses relative "/api" URLs only — see src/api/index.ts). - API_PROXY_TARGET: ${API_PROXY_TARGET:-http://backend:8000} + VITE_API_BASE_URL: /api ports: - "5173:5173" - - "8080:80" - volumes: - - ./frontend:/app - - /app/node_modules + depends_on: + - backend volumes: - pgdata: - redisdata: - qdrantdata: + pg_data: + qdrant_data: diff --git a/docs/AI-RPG_TZ_TDD.md b/docs/AI-RPG_TZ_TDD.md new file mode 100644 index 0000000..88f785f --- /dev/null +++ b/docs/AI-RPG_TZ_TDD.md @@ -0,0 +1,4418 @@ +# AI-RPG — Техническое задание для ИИ-агента (TDD) + +> **Версия документа:** 1.0.0 +> **Дата:** 2026-06-20 +> **Статус:** Draft, готов к реализации +> **Аудитория:** ИИ-агент-разработчик GLM-5.2), работающий по методологии TDD (Red-Green-Refactor) +> **Логотип:** `icon.png` (по умолчание идёт в составе архива, иные загружается через UI админ-настроек, см. §6.5 и §12.4) + +--- + +## Содержание + +1. [Глоссарий терминов](#1-глоссарий-терминов) +2. [Обзор проекта и цели](#2-обзор-проекта-и-цели) +3. [Архитектура системы (high-level)](#3-архитектура-системы-high-level) +4. [Стек технологий](#4-стек-технологий) +5. [Детальная схема БД](#5-детальная-схема-бд) +6. [API спецификация (OpenAPI)](#6-api-спецификация-openapi) +7. [SSE-протокол](#7-sse-протокол) +8. [Tool-сигнатуры (JSON-schema)](#8-tool-сигнатуры-json-schema) +9. [Потоки (flows) с sequence-диаграммами](#9-потоки-flows-с-sequence-диаграммами) +10. [Промпт-шаблоны и контекстный менеджер](#10-промпт-шаблоны-и-контекстный-менеджер) +11. [RAG-подсистема и валидация состояния](#11-rag-подсистема-и-валидация-состояния) +12. [Фронтенд-архитектура](#12-фронтенд-архитектура) +13. [DevOps / Deployment](#13-devops--deployment) +14. [TDD-методология и тестовая инфраструктура](#14-tdd-методология-и-тестовая-инфраструктура) +15. [Нефункциональные требования](#15-нефункциональные-требования) +16. [Стратегия обработки ошибок](#16-стратегия-обработки-ошибок) +17. [Roadmap реализации (по спринтам)](#17-roadmap-реализации-по-спринтам) +18. [Чек-листы и приёмочные критерии](#18-чек-листы-и-приёмочные-критерии) + +--- + +## 1. Глоссарий терминов + +В этом разделе зафиксированы машинно-читаемые определения всех ключевых терминов. ИИ-агент обязан использовать термины строго в соответствии с этими определениями; если в исходном коде или промптах встречается термин не из глоссария, его нужно добавить сюда через PR. + +| Термин | Определение | +|---|---| +| **AI-RPG** | Текстовая ролевая игра с ИИ-мастером (GM), построенная на FastAPI + PostgreSQL + React. | +| **GM (Game Master)** | Роль LLM, отвечающая за генерацию сценария, управление состоянием мира и нарратив. Не путать с пользователем-администратором. | +| **World (Мир)** | Совокупность схем сущностей, правил, окружения и текущего состояния. Один World = одна играбельная сессия. Хранится в таблице `worlds`. | +| **WorldPreset (Пресет мира)** | Шаблон мира, из которого можно создать новый World. Хранится в `world_presets`. Поля `status: draft \| ready \| archived`. | +| **Session (Сессия)** | Активное состояние игрока в конкретном World. В текущей архитектуре Session ≡ World: один мир — одна сессия, не сбрасывается между заходами. | +| **Environment (Окружение)** | JSON-блок, всегда присутствующий в промпте LLM. Содержит `player`, `current_location`, `plot_rails` и кастомные поля. Управляется через tool `env_update`. | +| **Plot Rails (Сюжетные рельсы)** | Подструктура environment: `{hooks: list[str], current_goals: list[str], completed_goals?: list[str]}`. Управляется через `update_plot_rails`. | +| **Entity (Сущность)** | Экземпляр типа из `world.schemas` (character, item, location, faction, ...). Хранится в `entities`. Привязан к миру. | +| **Step (Шаг)** | Единица итерации сессии: action игрока → response LLM. Хранится в `steps`. Содержит action, scene_text, tool_calls, metadata. | +| **Tool Call** | Вызов инструмента LLM в формате OpenAI function-calling. Любое изменение состояния мира происходит только через tool call. | +| **Phase (Фаза)** | Этап итерации orchestrator: Phase 1 (planner+executor), Phase 2 (writer), Phase 3 (persist+triggers+summary+suggestions). | +| **DeferredTrigger (Отложенный триггер)** | Событие, привязанное к игровому времени, активируемое когда `fire_at <= world.current_time`. Хранится в `deferred_triggers`. | +| **StoryEntry (Сюжетная запись)** | RAG-индексированный факт, не привязанный к сущности. Сам вектор хранится в Qdrant (collection `story_entries`), в PostgreSQL — только текст и метаданные. | +| **Summary (Сжатие контекста)** | LLM-сгенерированная выжимка N сообщений истории, заменяющая их в контексте когда история превышает порог сжатия. | +| **RAG (Retrieval-Augmented Generation)** | Подсистема семантического поиска по `story_entries` и `entities` через внешний векторный индекс **Qdrant**. Используется через tools `rag_query` / `rag_add`. PostgreSQL хранит только текст и метаданные; векторы живут в Qdrant-коллекциях. | +| **SSE (Server-Sent Events)** | Протокол стриминга прогресса LLM-итераций во фронтенд. Реализован через `sse-starlette`. | +| **Orchestrator** | Главный движок итерации сессии, координирующий три фазы. Реализован в `app/engine/game_master.py`. | +| **World Builder** | Поток первоначального создания мира из шаблона/формы. Реализован в `app/engine/world_builder.py`. | +| **World Editor** | Поток редактирования существующего мира через чат + ручные правки JSON. Реализован в `app/engine/world_editor.py`. | +| **Intro Scene** | Поток генерации вступительной сцены с первыми 1-3 действиями. Запускается после `world_builder`. | +| **TDD (Test-Driven Development)** | Методология разработки Red-Green-Refactor: сначала failing test, потом реализация, потом рефакторинг. Обязательна для всех изменений. | +| **Red / Green / Refactor** | Три фазы TDD-цикла. Red — пишем тест, который падает. Green — минимальная реализация, чтобы тест прошёл. Refactor — улучшаем код, не ломая тесты. | +| **Tool Result** | JSON-ответ инструмента, возвращаемый LLM как `tool_result` сообщение. Содержит либо `ok: true` + data, либо `ok: false` + error. | +| **State Patch** | JSON-патч для применения изменений к environment. Формат: `{field_path: new_value}` или `{field_path: {op: "inc", by: N}}`. | +| **Subagent** | Вложенный LLM-вызов для офэкранных действий (Phase 3.1). Реализован через tool `run_subagent`. | +| **LlmCallLog** | Запись о каждом вызове LLM: prompt, response, tokens, latency, error. Хранится в `llm_call_logs` в отдельной транзакции. | + +--- + +## 2. Обзор проекта и цели + +### 2.1. Что строим + +AI-RPG — это веб-приложение, в котором игрок ведёт текстовую ролевую игру с ИИ-мастером (GM). Игрок создаёт мир (или выбирает готовый пресет), настраивает персонажа, и далее вступает в пошаговое взаимодействие: каждое действие игрока обрабатывается трёхфазным orchestrator-ом, который генерирует нарратив, обновляет состояние мира через tool calls, и предлагает 1-3 следующих действия. + +Система спроектирована под небольшие локальные LLM (7B-параметров): все промпты оптимизированы под контекст 8K-32K токенов, tool calls используются для детерминированных изменений состояния (LLM не пишет свободный текст вида «вы получили 10 урона» — она вызывает `env_update` с патчем `player.stats.health`). + +### 2.2. Цели документа + +Этот документ — **техническое задание для ИИ-агента-разработчика**. Он преследует три цели: + +1. **Спроектировать все части архитектуры** целиком: детальную схему БД (со всеми колонками, типами, индексами), OpenAPI-спецификацию, JSON-schema всех tool-сигнатур, фронтенд-архитектуру, DevOps-конфигурацию, RAG-подсистему на Qdrant, контекстную оптимизацию и тестовую инфраструктуру. Документ самодостаточен — для реализации не требуется внешний источник. +2. **Зафиксировать методологию TDD (Red-Green-Refactor)** как обязательную для всех изменений: каждая фича начинается с failing test, реализуется минимально, рефакторится безопасно. +3. **Дать roadmap по спринтам** с приоритетами, артефактами и приёмочными критериями, чтобы ИИ-агент мог планировать последовательность работы. + +### 2.3. Ключевые принципы архитектуры + +Четыре принципа, зафиксированные в этом ТЗ и обязательные к исполнению: + +1. **World = Session.** Один мир — одна играбельная сессия. Сессия не сбрасывается между заходами игрока. Это означает, что таблица `worlds` хранит и "шаблонные" данные (schemas, environment_schema), и "живое" состояние (environment, current_time). Альтернатива с отдельной таблицей `sessions` рассматривалась и отвергнута — она усложняет UX (игроку нужно выбирать сессию) и не даёт преимуществ для single-player игры. + +2. **Environment как быстрый контекст.** Environment — это JSON-блок, всегда присутствующий в промпте LLM без вызова инструментов. LLM видит `player` (полное состояние персонажа), `current_location`, `plot_rails` и может дополнительно через tool `env_update` перетаскивать в environment релевантные Entity (например, NPC, с которым игрок сейчас взаимодействует). ИИ управляет тем, что находится в environment — это его "рабочая память". + +3. **Tools-first.** Любое изменение состояния мира, любая коммуникация с пользователем происходит через явные tool calls. LLM не пишет «вы получили 10 урона» в свободном тексте — она вызывает `env_update` с патчем `player.stats.health`. Свободный текст LLM остаётся только для нарратива, и даже там он возвращается через `submit_step` (Phase 2 writer). Это даёт четыре преимущества: детерминизм (изменения логируются), валидация (state_validator проверяет patch), обратная связь (LLM видит ошибки), UX-транспарентность (фронтенд показывает пузырьки tool calls). + +4. **Изоляция контекстов.** Каждый поток (world_builder, world_editor, orchestrator, intro_scene) имеет свой system-промпт и свой набор сообщений. Это критично для предотвращения галлюцинаций: orchestrator не должен видеть сообщения world_builder-а, иначе он может "продолжить" редактирование мира во время игры. + +### 2.4. Что НЕ входит в скоуп + +- Мультиплеер (несколько игроков в одном мире) — отложен до v2. +- Голосовой ввод/вывод — отложен. +- Интеграция с внешними VTT (Roll20, Foundry) — отложен. +- Мобильные нативные приложения — только веб. + +--- + +## 3. Архитектура системы (high-level) + +### 3.1. C4-диаграмма уровня Container + +```mermaid +flowchart TB + subgraph "Пользователь" + Player[Игрок] + Admin[Администратор] + end + + subgraph "Браузер" + FE[React Frontend
Vite + TS + zustand] + end + + subgraph "Сервер приложений" + Nginx[nginx
статика + reverse-proxy] + API[FastAPI Backend
uvicorn] + Engine[Engine Layer
game_master, world_builder,
world_editor, context] + Core[Core Layer
llm_client, rag, validator,
security, settings] + end + + subgraph "Внешние сервисы" + LLM[LLM Provider
OpenAI-compatible API] + end + + subgraph "Хранилище" + PG[(PostgreSQL 15+
реляционные данные)] + QD[(Qdrant
векторный индекс)] + Vol[(Volume data/
бэкапы, артефакты)] + end + + Player --> FE + Admin --> FE + FE -->|HTTP/SSE| Nginx + Nginx -->|/api/*| API + API --> Engine + Engine --> Core + Core -->|httpx| LLM + Core -->|embeddings API| LLM + Engine -->|SQLAlchemy async| PG + Core -->|SQLAlchemy async| PG + Core -->|qdrant-client| QD + API -->|логи| Vol + + style FE fill:#e1f5ff + style API fill:#fff3e0 + style PG fill:#e8f5e9 + style QD fill:#ede7f6 + style LLM fill:#fce4ec +``` + +### 3.2. Слои бэкенда + +Бэкенд разбит на шесть слоёв (директории внутри `app/`). Каждый слой имеет одну ответственность и не должен вызывать слои "через голову" (например, `api/` не должен напрямую дёргать `core/`, минуя `engine/`). + +| Слой | Директория | Ответственность | Зависит от | +|---|---|---|---| +| **API** | `app/api/` | FastAPI-роутеры: `auth`, `admin`, `worlds`, `sessions`, `presets`, `misc`. Только HTTP-логика, валидация Pydantic-схемами, вызов `engine/core`. | engine, core, schemas | +| **Engine** | `app/engine/` | Движок игры: `game_master` (основная итерация), `world_builder`, `world_editor`, `context` (построение промптов), `tools/` (инструменты). | core, models, prompts | +| **Core** | `app/core/` | Сквозные сервисы: `llm` (LLM-клиент), `settings_service`, `rag`, `state_validator`, `security` (JWT). | models | +| **Models** | `app/models/` | SQLAlchemy-модели всех таблиц. | — | +| **Prompts** | `app/prompts/` | Все системные промпты в виде Python-строк с `str.format()` интерполяцией. `get_prompt(stage, language)` — единственная точка доступа. | — | +| **Schemas** | `app/schemas/` | Pydantic-схемы для request/response API. **Не путать с JSON-schema мира** — это разные сущности. | — | + +**Правило циклических зависимостей:** слои `api → engine → core → models` образуют однонаправленный граф. `prompts` и `schemas` — листья, их может импортировать кто угодно, они никого не импортируют. + +### 3.3. Фронтенд-архитектура (overview) + +Подробно — в [разделе 12](#12-фронтенд-архитектура). Кратко: + +- **React 18 + TypeScript + Vite.** +- **Состояние:** zustand с доменными сторами: `authStore`, `uiStore`, `worldsStore`, `sessionStore`. +- **Локализация:** react-i18next, два языка (en, ru), расширяемо. +- **Стили:** Tailwind CSS. +- **UI-компоненты:** собственная минимальная библиотека в `src/components/ui/` (Button, Card, Input, Modal, Navbar) + shadcn-стиль `cn`-утилита. +- **Маршруты:** React Router v6. Страницы: `/login`, `/register`, `/worlds`, `/worlds/new`, `/worlds/:id/edit`, `/worlds/:id/play`, `/admin`. + +### 3.4. Принципы взаимодействия + +1. **REST + SSE.** Команды от клиента — REST (POST/GET/PUT/DELETE). Долгие операции (orchestrator, world_builder) возвращают SSE-стрим с прогрессом. +2. **JWT в Authorization header.** Все эндпоинты, кроме `/auth/*` и `/register/*`, требуют `Authorization: Bearer `. +3. **Idempotency.** Все POST-мутации принимают опциональный `Idempotency-Key` header; повторный запрос с тем же ключом возвращает кешированный результат. +4. **Soft delete.** World и Entity не удаляются физически, а помечаются `status='archived'` или `deleted_at`. Это позволяет откатывать итерации. + +--- + +## 4. Стек технологий + +Стек зафиксирован и не подлежит замене без явного ADR (Architecture Decision Record). Любое предложение сменить библиотеку должно быть оформлено как ADR в `docs/adr/`. + +### 4.1. Бэкенд + +| Компонент | Технология | Версия | Назначение | +|---|---|---|---| +| Язык | Python | 3.12+ | Основной язык бэкенда | +| Web-фреймворк | FastAPI | 0.110+ | HTTP-API, SSE, валидация Pydantic | +| ORM | SQLAlchemy | 2.x (async) | Работа с PostgreSQL | +| БД | PostgreSQL | 15+ | Основное реляционное хранилище (миры, сущности, шаги, логи) | +| Векторное хранилище | Qdrant | 1.8+ | Векторный индекс для RAG (семантический поиск по `story_entries` и `entities`). См. §11. | +| Qdrant client | qdrant-client | 1.8+ | Async-клиент к Qdrant (gRPC/HTTP) | +| HTTP-сервер | uvicorn | 0.27+ | ASGI-сервер | +| Reverse-proxy | nginx | 1.24+ | Раздача статики, проксирование API | +| SSE | sse-starlette | 1.6+ | Стриминг итераций | +| Auth | python-jose | 3.3+ | JWT | +| Password hashing | passlib[bcrypt] | 1.7+ | Хеширование паролей | +| HTTP-клиент | httpx | 0.27+ | LLM-вызовы | +| Migration | Alembic | 1.13+ | Схемные миграции | +| Testing | pytest + pytest-asyncio | 8.x | Unit/integration тесты | + +### 4.2. LLM-интеграция + +Собственный лёгкий клиент `app/core/llm.py` поверх `httpx`. Поддерживает: +- Обычный режим `chat/completions`. +- Streaming `chat/completions` с `stream=true`. +- `tool_calls` в OpenAI-формате (`tools`, `tool_choice`). +- Retry с экспоненциальной задержкой (`1s, 2s, 4s, max 3 attempts`). +- Логирование каждого вызова в `llm_call_logs` **в отдельной транзакции** (метод `LlmClient._write_log_safely`), чтобы лог выживал даже при откате основной транзакции. + +> **Архитектурное решение:** лог пишется в отдельной транзакции через `async with session.begin_nested()` + commit в конце. Если основная транзакция упала, лог остаётся. Это критично для отладки: оператор видит, какой именно запрос был отправлен и что вернулось, даже если итерация упала. + +### 4.3. Фронтенд + +| Компонент | Технология | Версия | Назначение | +|---|---|---|---| +| Язык | TypeScript | 5.x | Типизация | +| UI-фреймворк | React | 18.x | Компоненты | +| Сборщик | Vite | 5.x | Dev-сервер, бандлинг | +| State | zustand | 4.x | Глобальный стор | +| Локализация | react-i18next | 14.x | en, ru | +| Роутинг | react-router-dom | 6.x | SPA-маршруты | +| Стили | Tailwind CSS | 3.x | Utility-first CSS | +| HTTP | fetch + EventSource | native | REST + SSE | +| Testing | vitest + @testing-library/react | 1.x | Unit/component тесты | +| E2E | Playwright | 1.x | E2E тесты | + +### 4.4. Инфраструктура + +- **docker-compose** с четырьмя сервисами: `db` (PostgreSQL), `qdrant` (векторное хранилище), `backend`, `frontend`. +- **Volume** `data/` (путь из `.env` `DATA_DIR`) для бэкапов БД и артефактов. +- **Инициализация БД** — `app/migrations/init_db.py` создаёт все таблицы и заполняет дефолтные настройки (LLM, контекст) и встроенные пресеты. +- **Health-check** на `/api/health` возвращает `{status: "ok", db: true/false, llm: true/false}`. + +--- + +## 5. Детальная схема БД + +В этом разделе спроектированы **все** таблицы приложения. Схема нормализована, без дублирования; векторные данные (эмбеддинги) в PostgreSQL **не хранятся** — для RAG используется отдельный сервис Qdrant (см. §11). + +### 5.1. ER-диаграмма + +```mermaid +erDiagram + users ||--o{ world_presets : owns + users ||--o{ worlds : owns + users ||--o{ llm_call_logs : triggers + + world_presets ||--o{ worlds : "instantiated from" + + worlds ||--o{ entities : contains + worlds ||--o{ steps : "has iterations" + worlds ||--o{ deferred_triggers : "schedules" + worlds ||--o{ story_entries : "indexes facts" + + steps ||--o{ llm_call_logs : "produces" + steps ||--o{ step_tool_calls : "executes" + + settings { + uuid id PK + string key UK + jsonb value + timestamp updated_at + } + + users { + uuid id PK + string email UK + string username UK + string password_hash + boolean is_admin + boolean is_active + timestamp created_at + timestamp last_login_at + } + + world_presets { + uuid id PK + uuid owner_id FK + string name + text description + string language + jsonb rules + jsonb time_schema + jsonb schemas + jsonb environment_schema + jsonb environment_initial + string status + boolean is_public + timestamp created_at + timestamp updated_at + } + + worlds { + uuid id PK + uuid owner_id FK + uuid preset_id FK + string name + text description + string language + jsonb rules + jsonb time_schema + jsonb schemas + jsonb environment_schema + jsonb environment + jsonb plot_rails + string current_time + string status + timestamp created_at + timestamp updated_at + timestamp last_played_at + } + + entities { + uuid id PK + uuid world_id FK + string entity_type + string name + jsonb data + boolean is_in_environment + string qdrant_point_id + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + steps { + uuid id PK + uuid world_id FK + int sequence_number + string player_action + text scene_text + jsonb suggested_actions + jsonb tool_calls_summary + jsonb metadata + uuid phase1_log_id FK + uuid phase2_log_id FK + timestamp created_at + timestamp deleted_at + } + + deferred_triggers { + uuid id PK + uuid world_id FK + string fire_at + string event_type + jsonb payload + boolean is_fired + timestamp created_at + timestamp fired_at + } + + story_entries { + uuid id PK + uuid world_id FK + text content + string entry_type + string qdrant_point_id + jsonb metadata + timestamp created_at + } + + llm_call_logs { + uuid id PK + uuid user_id FK + uuid world_id FK + uuid step_id FK + string stage + string model + jsonb request_messages + jsonb response_message + jsonb tool_calls + int prompt_tokens + int completion_tokens + int latency_ms + string status + text error_message + timestamp created_at + } + + step_tool_calls { + uuid id PK + uuid step_id FK + string tool_name + jsonb arguments + jsonb result + boolean is_success + timestamp executed_at + } +``` + +### 5.2. Описание таблиц + +#### 5.2.1. `users` + +| Колонка | Тип | Ограничения | Назначение | +|---|---|---|---| +| `id` | UUID | PK, default `gen_random_uuid()` | Первичный ключ | +| `email` | VARCHAR(255) | UNIQUE, NOT NULL | Email пользователя | +| `username` | VARCHAR(64) | UNIQUE, NOT NULL | Логин | +| `password_hash` | VARCHAR(255) | NOT NULL | bcrypt hash | +| `is_admin` | BOOLEAN | NOT NULL, default `false` | Флаг администратора | +| `is_active` | BOOLEAN | NOT NULL, default `true` | Активна ли учётка | +| `created_at` | TIMESTAMPTZ | NOT NULL, default `now()` | Дата создания | +| `last_login_at` | TIMESTAMPTZ | nullable | Последний вход | + +**Индексы:** `idx_users_email` (UNIQUE), `idx_users_username` (UNIQUE). + +#### 5.2.2. `settings` + +Одна таблица для всех админ-настроек (key-value с JSONB value). Это позволяет добавлять новые настройки без миграций схемы. + +| Колонка | Тип | Ограничения | Назначение | +|---|---|---|---| +| `id` | UUID | PK | — | +| `key` | VARCHAR(128) | UNIQUE, NOT NULL | Ключ настройки (например `llm.api_url`) | +| `value` | JSONB | NOT NULL | Значение (строка, число, объект, массив) | +| `description` | TEXT | nullable | Человекочитаемое описание | +| `updated_at` | TIMESTAMPTZ | NOT NULL, default `now()` | Последнее изменение | + +**Ключи настроек (seed-данные):** + +| Ключ | Тип value | Назначение | +|---|---|---| +| `llm.api_url` | string | URL OpenAI-compatible endpoint | +| `llm.api_key` | string | API-ключ (зашифрован на уровне приложения) | +| `llm.model` | string | Имя модели (например `qwen2.5-7b-instruct`) | +| `llm.temperature_orchestrator` | number | Phase 1, default 0.7 | +| `llm.temperature_writer` | number | Phase 2, default 0.85 | +| `llm.max_tokens` | integer | Лимит completion | +| `llm.timeout_seconds` | integer | Timeout на вызов, default 60 | +| `embeddings.provider` | string | `offline_hash` \| `openai`. Если `openai` и `embeddings.api_url`/`api_key` пустые — fallback на `llm.api_url`/`api_key` (см. §11.6.2). | +| `embeddings.api_url` | string | URL OpenAI-compatible embeddings endpoint. Если пусто — fallback на `llm.api_url`. | +| `embeddings.api_key` | string | API-ключ embeddings. Если пусто — fallback на `llm.api_key`. | +| `embeddings.model` | string | Имя модели эмбеддингов (default `text-embedding-3-small`) | +| `embeddings.dimension` | integer | Размерность вектора, default 1536. **Кнопка «Авто-проба»** в UI определяет автоматически (§11.6.3). | +| `embeddings.timeout_seconds` | integer | Timeout embeddings API, default 30 | +| `embeddings.batch_size` | integer | Размер батча для embedding API, default 32 | +| `embeddings.cache_ttl_seconds` | integer | TTL LRU-кеша для query embeddings, default 300 | +| `embeddings.max_text_chars` | integer | Урезка текста перед эмбеддингом, default 4000 | +| `context.guaranteed_messages` | integer | Сколько последних сообщений всегда в контексте, default 10 | +| `context.compression_threshold_messages` | integer | Порог сжатия, default 20 | +| `context.compression_threshold_tokens` | integer | Альтернативный порог по токенам, default 6000 | +| `context.scene_text_truncate_tokens` | integer | Урезка `scene_text` в recent messages, default 500 | +| `context.auto_rag_on_entity_mention` | boolean | Авто-вызов `rag_query` при упоминании сущности, default false | +| `context.safety_margin_tokens` | integer | Резерв от края context window, default 500 | +| `qdrant.url` | string | URL Qdrant-инстанса (например `http://qdrant:6333`) | +| `qdrant.api_key` | string | API-ключ Qdrant (если включена авторизация) | +| `qdrant.collection_prefix` | string | Префикс для коллекций (для multi-tenant деплоя), default `""` | +| `game.deferred_triggers_enabled` | boolean | Включены ли отложенные триггеры, default true | +| `game.max_substeps_per_iteration` | integer | Лимит шагов в Phase 1, default 8 | +| `game.max_suggested_actions` | integer | Лимит действий в конце итерации, default 3 | +| `ui.page_title` | string | Заголовок вкладки браузера | +| `ui.favicon_url` | string | URL favicon (загружается через `/api/admin/upload-icon`, см. §6.5) | +| `ui.logo_url` | string | URL логотипа в шапке приложения | +| `ui.og_image_url` | string | URL Open Graph image (для соц-превью) | +| `admin.setup_token` | string | Токен для создания первого админа | + +#### 5.2.3. `world_presets` + +Дополнительно спроектированные колонки: + +| Колонка | Тип | Назначение | +|---|---|---| +| `is_public` | BOOLEAN, default `false` | Опубликован ли пресет в галерее | +| `version` | INTEGER, default 1 | Версия пресета для контроля обновлений | + +#### 5.2.4. `worlds` + +| Колонка | Тип | Ограничения | Назначение | +|---|---|---|---| +| `id` | UUID | PK | — | +| `owner_id` | UUID | FK→users.id, NOT NULL | Владелец | +| `preset_id` | UUID | FK→world_presets.id, nullable | Если создан из пресета | +| `name` | VARCHAR(255) | NOT NULL | Название | +| `description` | TEXT | nullable | Описание | +| `language` | VARCHAR(8) | NOT NULL | Код языка (en, ru) | +| `rules` | JSONB | NOT NULL, default `'[]'` | Массив строк правил | +| `time_schema` | JSONB | NOT NULL, default `'{"hours_in_day":24,"initial_date":"day_1_hour_8"}'` | Схема времени | +| `schemas` | JSONB | NOT NULL | Массив схем сущностей | +| `environment_schema` | JSONB | NOT NULL | Массив определений полей окружения | +| `environment` | JSONB | NOT NULL | Живое состояние окружения | +| `plot_rails` | JSONB | NOT NULL, default `'{"hooks":[],"current_goals":[],"completed_goals":[]}'` | Сюжетные рельсы (дублируются из environment для быстрого доступа) | +| `current_time` | VARCHAR(32) | NOT NULL | Текущее игровое время в формате `day_D_hour_H[_min_M]` | +| `status` | VARCHAR(32) | NOT NULL, default `'draft'` | `draft` \| `ready` \| `archived` | +| `intro_scene` | TEXT | nullable | Сгенерированная вступительная сцена | +| `created_at` | TIMESTAMPTZ | NOT NULL, default `now()` | — | +| `updated_at` | TIMESTAMPTZ | NOT NULL, default `now()` | — | +| `last_played_at` | TIMESTAMPTZ | nullable | Последняя итерация | + +**Индексы:** `idx_worlds_owner_id`, `idx_worlds_status`, `idx_worlds_last_played_at`. + +#### 5.2.5. `entities` + +| Колонка | Тип | Ограничения | Назначение | +|---|---|---|---| +| `id` | UUID | PK | — | +| `world_id` | UUID | FK→worlds.id, NOT NULL | Принадлежность миру | +| `entity_type` | VARCHAR(64) | NOT NULL | Тип из `world.schemas` (character, item, location, ...) | +| `name` | VARCHAR(255) | NOT NULL | Имя/название | +| `data` | JSONB | NOT NULL | Полные данные сущности по schema | +| `is_in_environment` | BOOLEAN | NOT NULL, default `false` | Находится ли сущность в environment (для быстрого доступа без JOIN) | +| `qdrant_point_id` | VARCHAR(64) | nullable | ID точки в Qdrant-коллекции `entities`. `NULL` = вектор не посчитан (фоновый job дозаполнит). Текст для эмбеддинга формируется из `name` + JSON `data`. | +| `embedding_status` | VARCHAR(16) | NOT NULL, default `'pending'` | `pending` \| `indexed` \| `failed`. Позволяет фоновому воркеру выбирать «голодные» записи. | +| `created_at` | TIMESTAMPTZ | NOT NULL, default `now()` | — | +| `updated_at` | TIMESTAMPTZ | NOT NULL, default `now()` | — | +| `deleted_at` | TIMESTAMPTZ | nullable | Soft delete. При soft-delete точка в Qdrant тоже удаляется через `qdrant_client.delete()` (best-effort). | + +**Индексы:** `idx_entities_world_id`, `idx_entities_world_type` (world_id, entity_type), `idx_entities_embedding_status` (embedding_status) WHERE deleted_at IS NULL — для фонового индексатора. Векторный поиск выполняется в Qdrant, не в PostgreSQL; IVFFLAT/HNSW-индексы здесь не нужны. + +#### 5.2.6. `steps` + +| Колонка | Тип | Ограничения | Назначение | +|---|---|---|---| +| `id` | UUID | PK | — | +| `world_id` | UUID | FK→worlds.id, NOT NULL | — | +| `sequence_number` | INTEGER | NOT NULL | Монотонный номер шага в мире | +| `player_action` | TEXT | NOT NULL | Действие игрока (или `__suggested__:N` если выбрал заготовку) | +| `scene_text` | TEXT | nullable | Нарратив из Phase 2 | +| `scene_delta_time` | VARCHAR(32) | nullable | Дельта времени из Phase 2 (`[year_Y][days_D][hours_H][min_M]`) | +| `suggested_actions` | JSONB | NOT NULL, default `'[]'` | Массив 1-3 следующих действий | +| `tool_calls_summary` | JSONB | NOT NULL, default `'[]'` | Краткая сводка вызванных инструментов (для UI-пузырьков) | +| `metadata` | JSONB | NOT NULL, default `'{}'` | Доп. метаданные (latency, token counts, model versions) | +| `phase1_log_id` | UUID | FK→llm_call_logs.id, nullable | Лог Phase 1 | +| `phase2_log_id` | UUID | FK→llm_call_logs.id, nullable | Лог Phase 2 | +| `phase3_summary_log_id` | UUID | FK→llm_call_logs.id, nullable | Лог summary (если был) | +| `created_at` | TIMESTAMPTZ | NOT NULL, default `now()` | — | +| `deleted_at` | TIMESTAMPTZ | nullable | Soft delete (для отката) | + +**Ограничения:** UNIQUE (world_id, sequence_number) — номера не могут дублироваться. + +**Индексы:** `idx_steps_world_seq` (world_id, sequence_number DESC), `idx_steps_created_at`. + +#### 5.2.7. `deferred_triggers` + +| Колонка | Тип | Ограничения | Назначение | +|---|---|---|---| +| `id` | UUID | PK | — | +| `world_id` | UUID | FK→worlds.id, NOT NULL | — | +| `fire_at` | VARCHAR(32) | NOT NULL | Игровое время срабатывания | +| `event_type` | VARCHAR(64) | NOT NULL | Тип события (например `spawn_enemy`, `weather_change`, `quest_update`) | +| `payload` | JSONB | NOT NULL, default `'{}'` | Данные события | +| `is_fired` | BOOLEAN | NOT NULL, default `false` | Сработало ли | +| `created_at` | TIMESTAMPTZ | NOT NULL, default `now()` | — | +| `fired_at` | TIMESTAMPTZ | nullable | Когда сработало | + +**Индексы:** `idx_triggers_world_pending` (world_id, is_fired, fire_at) — для быстрого поиска pending triggers, которые пора активировать. + +#### 5.2.8. `story_entries` + +| Колонка | Тип | Ограничения | Назначение | +|---|---|---|---| +| `id` | UUID | PK | — | +| `world_id` | UUID | FK→worlds.id, NOT NULL | — | +| `content` | TEXT | NOT NULL | Текст факта | +| `entry_type` | VARCHAR(64) | NOT NULL | `fact` \| `event` \| `relationship` \| `secret` \| `summary` | +| `qdrant_point_id` | VARCHAR(64) | nullable | ID точки в Qdrant-коллекции `story_entries`. `NULL` = вектор ещё не посчитан. | +| `embedding_status` | VARCHAR(16) | NOT NULL, default `'pending'` | `pending` \| `indexed` \| `failed` | +| `metadata` | JSONB | NOT NULL, default `'{}'` | Связи с entity_id, step_id, и т.д. | +| `created_at` | TIMESTAMPTZ | NOT NULL, default `now()` | — | + +**Индексы:** `idx_story_world_type` (world_id, entry_type), `idx_story_status` (embedding_status) WHERE qdrant_point_id IS NULL — для фонового индексатора. Векторный поиск — в Qdrant; PostgreSQL хранит только текст и связь с world/entity/step. + +#### 5.2.9. `llm_call_logs` + +| Колонка | Тип | Ограничения | Назначение | +|---|---|---|---| +| `id` | UUID | PK | — | +| `user_id` | UUID | FK→users.id, nullable | Кто инициировал (nullable для системных вызовов) | +| `world_id` | UUID | FK→worlds.id, nullable | В каком мире | +| `step_id` | UUID | FK→steps.id, nullable | На каком шаге | +| `stage` | VARCHAR(64) | NOT NULL | `world_builder` \| `world_editor` \| `orchestrator_phase1` \| `orchestrator_phase2` \| `orchestrator_phase3_summary` \| `intro_scene` \| `subagent` | +| `model` | VARCHAR(128) | NOT NULL | Имя модели | +| `request_messages` | JSONB | NOT NULL | Полный массив сообщений | +| `request_tools` | JSONB | nullable | Описание tools | +| `response_message` | JSONB | NOT NULL | Ответ LLM | +| `tool_calls` | JSONB | nullable | Извлечённые tool_calls | +| `prompt_tokens` | INTEGER | nullable | — | +| `completion_tokens` | INTEGER | nullable | — | +| `latency_ms` | INTEGER | nullable | — | +| `temperature` | FLOAT | nullable | — | +| `status` | VARCHAR(32) | NOT NULL | `ok` \| `timeout` \| `api_error` \| `parse_error` \| `validation_error` | +| `error_message` | TEXT | nullable | — | +| `created_at` | TIMESTAMPTZ | NOT NULL, default `now()` | — | + +**Индексы:** `idx_logs_world_created` (world_id, created_at DESC), `idx_logs_stage` (stage), `idx_logs_status` (status). + +#### 5.2.10. `step_tool_calls` + +Дочерняя таблица `steps` — детальная запись каждого tool call внутри шага. Нужна для админ-панели и аудита. + +| Колонка | Тип | Назначение | +|---|---|---| +| `id` | UUID PK | — | +| `step_id` | UUID FK→steps.id | — | +| `tool_name` | VARCHAR(64) | Имя инструмента | +| `arguments` | JSONB | Аргументы вызова | +| `result` | JSONB | Что вернул инструмент | +| `is_success` | BOOLEAN | Успешен ли вызов | +| `executed_at` | TIMESTAMPTZ | — | + +### 5.3. SQL DDL (фрагмент) + +Полный DDL генерируется Alembic, но для справки — ключевые таблицы: + +```sql +-- pgvector НЕ используется. Векторное хранилище — внешний сервис Qdrant. +-- PostgreSQL хранит только qdrant_point_id (строка-идентификатор точки). + +-- users +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + username VARCHAR(64) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT false, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +-- worlds (ключевые поля) +CREATE TABLE worlds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + preset_id UUID REFERENCES world_presets(id) ON DELETE SET NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + language VARCHAR(8) NOT NULL, + rules JSONB NOT NULL DEFAULT '[]'::jsonb, + time_schema JSONB NOT NULL DEFAULT '{"hours_in_day":24,"initial_date":"day_1_hour_8"}'::jsonb, + schemas JSONB NOT NULL, + environment_schema JSONB NOT NULL, + environment JSONB NOT NULL, + plot_rails JSONB NOT NULL DEFAULT '{"hooks":[],"current_goals":[],"completed_goals":[]}'::jsonb, + current_time VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'draft', + intro_scene TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_played_at TIMESTAMPTZ +); +CREATE INDEX idx_worlds_owner_id ON worlds(owner_id); +CREATE INDEX idx_worlds_status ON worlds(status); +CREATE INDEX idx_worlds_last_played_at ON worlds(last_played_at DESC); + +-- entities (без вектора; векторы в Qdrant, коллекция `entities`) +CREATE TABLE entities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + world_id UUID NOT NULL REFERENCES worlds(id) ON DELETE CASCADE, + entity_type VARCHAR(64) NOT NULL, + name VARCHAR(255) NOT NULL, + data JSONB NOT NULL, + is_in_environment BOOLEAN NOT NULL DEFAULT false, + qdrant_point_id VARCHAR(64), + embedding_status VARCHAR(16) NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); +CREATE INDEX idx_entities_world_id ON entities(world_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_entities_world_type ON entities(world_id, entity_type) WHERE deleted_at IS NULL; +CREATE INDEX idx_entities_embedding_status ON entities(embedding_status) WHERE deleted_at IS NULL AND qdrant_point_id IS NULL; + +-- story_entries (без вектора; векторы в Qdrant, коллекция `story_entries`) +CREATE TABLE story_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + world_id UUID NOT NULL REFERENCES worlds(id) ON DELETE CASCADE, + content TEXT NOT NULL, + entry_type VARCHAR(64) NOT NULL, + qdrant_point_id VARCHAR(64), + embedding_status VARCHAR(16) NOT NULL DEFAULT 'pending', + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_story_world_type ON story_entries(world_id, entry_type); +CREATE INDEX idx_story_status ON story_entries(embedding_status) WHERE qdrant_point_id IS NULL; + +-- llm_call_logs +CREATE TABLE llm_call_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + world_id UUID REFERENCES worlds(id) ON DELETE SET NULL, + step_id UUID REFERENCES steps(id) ON DELETE SET NULL, + stage VARCHAR(64) NOT NULL, + model VARCHAR(128) NOT NULL, + request_messages JSONB NOT NULL, + request_tools JSONB, + response_message JSONB NOT NULL, + tool_calls JSONB, + prompt_tokens INTEGER, + completion_tokens INTEGER, + latency_ms INTEGER, + temperature FLOAT, + status VARCHAR(32) NOT NULL, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_logs_world_created ON llm_call_logs(world_id, created_at DESC); +CREATE INDEX idx_logs_stage ON llm_call_logs(stage); +CREATE INDEX idx_logs_status ON llm_call_logs(status); +``` + +### 5.4. Миграции + +- **Инструмент:** Alembic. +- **Директория:** `app/migrations/versions/`. +- **Первая миграция:** `001_initial_schema.py` создаёт все таблицы. pgvector НЕ нужен. Дополнительно к миграции БД — startup-хук `app/migrations/init_qdrant.py` создаёт коллекции `entities` и `story_entries` в Qdrant с правильной размерностью (см. §11.6, авто-проба размерности). +- **Seed-скрипт:** `app/migrations/seed.py` заполняет `settings` (значения по умолчанию) и встроенные `world_presets` (минимум 2: фэнтези и sci-fi). +- **Rollback:** каждая миграция должна иметь `downgrade()`. Перед деплоем — обязательный прогон `alembic downgrade --sql +1` в staging для проверки. + +**Порядок создания таблиц (для миграции 001):** +1. `users` (нет FK) +2. `settings` (нет FK) +3. `world_presets` (FK→users) +4. `worlds` (FK→users, world_presets) +5. `entities` (FK→worlds) +6. `story_entries` (FK→worlds) +7. `deferred_triggers` (FK→worlds) +8. `llm_call_logs` (FK→users, worlds) +9. `steps` (FK→worlds, llm_call_logs) +10. `step_tool_calls` (FK→steps) +11. Все индексы и constraints + +--- + +## 6. API спецификация (OpenAPI) + +Базовый префикс всех маршрутов: `/api`. Все эндпоинты, кроме `/api/auth/*` и `/api/register/*`, требуют заголовок `Authorization: Bearer `. Ответы — JSON. Ошибки — в формате `{error: {code: string, message: string, details?: object}}`. + +### 6.1. Аутентификация и регистрация + +#### `POST /api/register` +Регистрация нового пользователя. Открывать только если в БД нет ни одного админа — иначе нужен admin-token. + +**Request body:** +```json +{ + "email": "user@example.com", + "username": "player1", + "password": "secret123", + "password_confirm": "secret123" +} +``` + +**Response 201:** +```json +{ + "id": "uuid", + "email": "user@example.com", + "username": "player1", + "is_admin": false, + "created_at": "2026-06-20T10:00:00Z" +} +``` + +**Ошибки:** `400 email_already_exists`, `400 username_already_exists`, `400 password_mismatch`, `400 weak_password`. + +#### `POST /api/register/admin` +Регистрация администратора. Требует query-параметр `?token=`. Если токен не совпадает с `admin.setup_token` из `settings` — `403`. + +#### `POST /api/auth/login` +**Request body:** +```json +{ + "login": "user@example.com", // email ИЛИ username + "password": "secret123" +} +``` + +**Response 200:** +```json +{ + "access_token": "eyJ...", + "token_type": "bearer", + "expires_in": 86400, + "user": { "id": "uuid", "username": "player1", "is_admin": false } +} +``` + +**Ошибки:** `401 invalid_credentials`, `403 account_disabled`. + +#### `POST /api/auth/refresh` +Обновление токена. Требует текущий валидный JWT. Возвращает новый. + +#### `POST /api/auth/logout` +Инвалидирует токен (добавляет в blacklist до истечения). Возвращает `204 No Content`. + +#### `GET /api/auth/me` +Возвращает профиль текущего пользователя. + +### 6.2. Миры (Worlds) + +#### `GET /api/worlds` +Список миров текущего пользователя. Поддержка pagination. + +**Query:** `?page=1&per_page=20&status=ready&sort=last_played_at`. + +**Response 200:** +```json +{ + "items": [ + { + "id": "uuid", + "name": "Тёмное подземелье", + "description": "...", + "language": "ru", + "status": "ready", + "last_played_at": "2026-06-19T20:00:00Z", + "current_time": "day_3_hour_14_min_30", + "preview_player_name": "Эрик" + } + ], + "total": 42, + "page": 1, + "per_page": 20 +} +``` + +#### `POST /api/worlds` +Создание мира. Запускает асинхронный процесс `world_builder` (см. [раздел 9.1](#91-поток-создания-мира-world_builder)). Возвращает `world_id` и SSE-канал. + +**Request body:** +```json +{ + "mode": "preset", // "preset" | "form" + "preset_id": "uuid", // если mode=preset + "form_data": { // если mode=form + "setting": "post-apocalyptic underground bunker", + "rules": ["..."], + "character_concept": "lone scavenger" + }, + "name": "Мой бункер", + "language": "ru", + "player_name": "Эрик", + "notes": "Хочу упор на survival horror" +} +``` + +**Response 202:** +```json +{ + "world_id": "uuid", + "stream_url": "/api/sessions/worlds/uuid/builder/stream" +} +``` + +#### `GET /api/worlds/{id}` +Полные данные мира (для страницы редактирования/игры). Включает `schemas`, `environment_schema`, `environment`, `plot_rails`, `current_time`. + +#### `PATCH /api/worlds/{id}` +Ручное обновление полей мира (только owner). Используется в world_editor для прямых правок JSON. + +**Request body:** partial World object. + +#### `DELETE /api/worlds/{id}` +Soft delete: ставит `status='archived'`. Hard delete — только через админ-панель. + +#### `POST /api/worlds/{id}/edit` +Запускает world_editor stream. **Request body:** +```json +{ "instruction": "Добавь игроку меч в инвентарь" } +``` +**Response 202:** `{ "stream_url": "/api/sessions/worlds/uuid/editor/stream" }` + +### 6.3. Сессии (Sessions) — игровая итерация + +#### `GET /api/sessions/worlds/{id}/state` +Возвращает текущее состояние для рендера страницы игры: последние N шагов, environment, suggested_actions. + +**Response 200:** +```json +{ + "world": { "id": "uuid", "name": "...", "current_time": "..." }, + "environment": { "player": {...}, "current_location": "...", "plot_rails": {...} }, + "recent_steps": [ { "id": "uuid", "sequence_number": 42, "scene_text": "...", "suggested_actions": [...] } ], + "next_actions": ["Открыть дверь", "Осмотреть комнату", "Подойти к окну"] +} +``` + +#### `POST /api/sessions/worlds/{id}/iterate` +Запускает orchestrator (3 фазы). Возвращает SSE URL. + +**Request body:** +```json +{ + "action": "Я открываю дверь мечом", + "action_source": "custom" // "custom" | "suggested" +} +``` + +**Response 202:** +```json +{ "stream_url": "/api/sessions/worlds/uuid/iterate/stream", "step_id": "uuid" } +``` + +#### `POST /api/sessions/worlds/{id}/retry` +Повторная генерация последнего шага (если предыдущая упала). Использует тот же `action`. + +#### `POST /api/sessions/worlds/{id}/rollback` +Откат последнего шага. Soft-deletes последний `step`, восстанавливает предыдущее состояние environment из истории. + +### 6.4. Пресеты (Presets) + +#### `GET /api/presets` +Список публичных пресетов (status=ready AND is_public=true) + пресеты текущего пользователя. + +#### `POST /api/presets` (admin only) +Создание нового пресета. + +#### `GET /api/presets/{id}` +Полные данные пресета. + +#### `PATCH /api/presets/{id}` (owner/admin) +Редактирование пресета. + +#### `DELETE /api/presets/{id}` (owner/admin) +Архивация пресета. + +### 6.5. Админка + +Все эндпоинты требуют `is_admin=true`. + +#### `GET /api/admin/settings` +Возвращает все настройки (кроме секретных значений, которые маскируются). + +#### `PATCH /api/admin/settings` +Обновление настроек. **Request body:** `{ "llm.api_url": "http://...", "llm.model": "..." }`. + +#### `GET /api/admin/llm-logs` +Логи вызовов LLM с фильтрами. **Query:** `?world_id=&stage=&status=&page=&per_page=&from=&to=`. + +#### `GET /api/admin/llm-logs/{id}` +Полный лог: `request_messages`, `response_message`, `tool_calls`, `error_message`. + +#### `GET /api/admin/users` +Список пользователей. **PATCH** — изменение `is_admin`, `is_active`. + +#### `GET /api/admin/stats` +Сводная статистика: количество пользователей, миров, итераций за период, средний latency LLM. + +#### Тестовые эндпоинты (диагностика подключений) + +Эти эндпоинты доступны только админу (`is_admin=true`). Принимают **query params** (а не body) — это позволяет проверить настройки до сохранения в `settings`. Каждый эндпоинт возвращает `{ok: bool, elapsed_ms: int, ...детали}` и логирует результат в `llm_call_logs` со stage=`test_*`. + +##### `POST /api/admin/test/llm?api_url=&api_key=&model=` +Проверяет базовую связность с LLM. Отправляет промпт `"Reply with exactly: OK"` (max_tokens=10, temperature=0). **Возвращает:** +```json +{ + "ok": true, + "response": "OK", + "model": "qwen2.5-7b-instruct", + "elapsed_ms": 412, + "prompt_tokens": 12, + "completion_tokens": 2 +} +``` +При ошибке: `{"ok": false, "error": {"code": "connection_failed", "message": "..."}, "elapsed_ms": 5000}`. + +##### `POST /api/admin/test/llm-tools?api_url=&api_key=&model=` +Проверяет, что LLM корректно вызывает инструменты (function calling). Отправляет промпт `"What is 2+2? Use the calc tool."` + один инструмент `calc(expression)`. **Возвращает:** +```json +{ + "ok": true, + "tool_calls": [{"name": "calc", "arguments": {"expression": "2+2"}}], + "elapsed_ms": 580, + "has_tool_calls": true +} +``` +Если `has_tool_calls=false` — модель не поддерживает function calling в текущей конфигурации; админу показывается warning. + +##### `POST /api/admin/test/embeddings?api_url=&api_key=&model=&provider=` +Проверяет эмбеддинг-провайдера. Отправляет `"hello world"` в embeddings API. **Возвращает:** +```json +{ + "ok": true, + "dimension": 1536, + "model": "text-embedding-3-small", + "first_5_values": [0.0123, -0.0456, 0.0789, -0.0321, 0.0543], + "elapsed_ms": 142 +} +``` +Если `provider=offline_hash` — endpoint не делает HTTP-запрос, возвращает dimension из локального `HashEmbedder`. + +##### `POST /api/admin/test/embeddings/probe-dimension?api_url=&api_key=&model=&provider=` +То же что и `/test/embeddings`, но возвращает **только dimension** — используется UI-кнопкой «Авто-проба размерности» (§12.4). **Возвращает:** `{"ok": true, "dimension": 1536, "elapsed_ms": 142}`. После этого UI предлагает кнопку «Сохранить 1536 в `embeddings.dimension`». + +#### Загрузка иконки (favicon/logo) + +##### `POST /api/admin/upload-icon` +Принимает `multipart/form-data` с полем `file` (PNG/SVG, до 1MB) и опциональным `kind` (`favicon` | `logo` | `og_image`). Сохраняет файл в `${DATA_DIR}/assets/{kind}_{timestamp}.{ext}`, обновляет `settings.ui.favicon_url` (или `ui.logo_url` / `ui.og_image_url`) на относительный URL `/static/assets/{kind}_{timestamp}.{ext}`. **Возвращает:** +```json +{ + "ok": true, + "kind": "favicon", + "url": "/static/assets/favicon_20260620_142312.png", + "size_bytes": 12345 +} +``` +Статические файлы из `${DATA_DIR}/assets/` раздаются FastAPI через `StaticFiles` mount на `/static/assets`. + +### 6.6. Misc + +#### `GET /api/health` +Без авторизации. `{ "status": "ok", "db": true, "llm": true, "version": "1.0.0" }`. + +#### `GET /api/i18n/{lang}` +Возвращает JSON с переводами для языка (используется фронтендом для ленивой подгрузки). + +### 6.7. Стандартные коды ошибок + +| HTTP | code | Когда | +|---|---|---| +| 400 | `validation_error` | Pydantic-валидация не прошла | +| 400 | `password_mismatch` | `password != password_confirm` | +| 400 | `weak_password` | Пароль < 8 символов или в blacklist | +| 401 | `invalid_credentials` | Неверный логин/пароль | +| 401 | `token_expired` | JWT истёк | +| 401 | `token_invalid` | JWT невалиден | +| 403 | `account_disabled` | `is_active=false` | +| 403 | `not_admin` | Требуется админ | +| 403 | `not_owner` | Не владелец ресурса | +| 404 | `not_found` | Ресурс не найден | +| 409 | `state_conflict` | Оптимистичная блокировка не прошла | +| 422 | `world_invalid` | `validate_world()` упал | +| 429 | `rate_limited` | Превышен лимит (см. NFR §15.2) | +| 500 | `internal_error` | Необработанная ошибка | +| 502 | `llm_unavailable` | LLM-провайдер недоступен | +| 504 | `llm_timeout` | LLM-вызов превысил timeout | + +--- + +## 7. SSE-протокол + +SSE (Server-Sent Events) используется для всех долгих операций: `world_builder`, `world_editor`, `orchestrator`, `intro_scene`. Все SSE-каналы — односторонние (server→client), для команд от клиента используется REST. + +### 7.1. Заголовки и подключение + +``` +GET /api/sessions/worlds/{id}/iterate/stream +Accept: text/event-stream +Authorization: Bearer +Cache-Control: no-cache +``` + +Каждое событие: +``` +event: +data: + +``` + +Двойной `\n` обязателен. Heartbeat каждые 15 секунд: +``` +event: ping +data: {"ts": "2026-06-20T10:00:00Z"} + +``` + +### 7.2. Универсальные события + +Эти события могут прийти в любом SSE-канале: + +| Event | Data | Назначение | +|---|---|---| +| `ping` | `{ts}` | Heartbeat, чтобы соединение не закрылось | +| `error` | `{code, message, details?}` | Фатальная ошибка, стрим закрывается | +| `warning` | `{code, message}` | Некритичная проблема, стрим продолжается | +| `progress` | `{phase, step, total_steps?, message?}` | Прогресс текущей фазы | +| `done` | `{result}` | Успешное завершение, стрим закрывается | + +### 7.3. События orchestrator (итерация сессии) + +| Event | Data | Когда | +|---|---|---| +| `phase_start` | `{phase: 1\|2\|3, name}` | Начало фазы | +| `phase_end` | `{phase, duration_ms}` | Конец фазы | +| `tool_call` | `{tool, arguments, result?, is_success}` | LLM вызвала инструмент | +| `llm_call_start` | `{stage, model}` | Начало LLM-вызова | +| `llm_call_end` | `{stage, latency_ms, tokens}` | Конец LLM-вызова | +| `scene_chunk` | `{text}` | Streaming-чанк текста из Phase 2 | +| `scene_complete` | `{text, delta_time}` | Полный текст сцены | +| `suggested_actions` | `{actions: [...]}` | 1-3 следующих действия | +| `trigger_fired` | `{trigger_id, event_type, summary}` | Сработал отложенный триггер | +| `summary_generated` | `{summary_id, message_range}` | Сгенерирован summary | +| `iteration_complete` | `{step_id, sequence_number}` | Полное завершение | + +### 7.4. События world_builder + +| Event | Data | +|---|---| +| `step` | `{step: "generating_schema", message: "..."}` | +| `world_schema_generated` | `{schemas, environment_schema}` | +| `environment_generated` | `{environment}` | +| `entities_generated` | `{entities: [...]}` | +| `intro_scene_chunk` | `{text}` | +| `intro_scene_complete` | `{text, suggested_actions}` | + +### 7.5. События world_editor + +| Event | Data | +|---|---| +| `llm_thinking` | `{}` | +| `clarification` | `{question, options?}` | LLM спрашивает игрока через `ask_user` | +| `change_proposed` | `{diff: [{path, op, old, new}]}` | +| `comment` | `{text}` | Комментарий LLM игроку | +| `apply_changes` | `{}` | Игрок принял, изменения применены | +| `discard_changes` | `{}` | Игрок отклонил | + +### 7.6. Reconnect-стратегия + +- Фронтенд использует `EventSource` (нативный) с автоконнектом. +- При reconnect клиент шлёт `Last-Event-ID` header — сервер возобновляет с пропущенных событий. +- Если `Last-Event-ID` старше 5 минут — сервер отвечает `410 Gone`, клиент делает полный re-fetch через REST. +- Каждое событие имеет `id` поле для `Last-Event-ID`: + ``` + id: step_42_tool_3 + event: tool_call + data: {...} + ``` + +### 7.7. Close-коды (если SSE через WebSocket-fallback) + +Не используется в текущей архитектуре (только нативный EventSource), но зарезервировано для будущего: +- 4000 — unauthorized +- 4001 — world_not_found +- 4002 — rate_limited +- 4003 — server_shutdown + +--- + +## 8. Tool-сигнатуры (JSON-schema) + +Это **центральный контракт** системы. Все инструменты реализованы как Python-функции в `app/engine/tools/`, и регистрируются в `app/engine/tools/registry.py`. LLM получает их описание через `LlmClient` в параметре `tools` OpenAI-формата. + +**Категории инструментов:** + +| Категория | Когда вызываются | Примеры | +|---|---|---| +| **Game tools** | Внутри Phase 1 orchestrator (между `submit_step`). Мутируют состояние мира. | `entity_create`, `entity_get`, `entity_list`, `entity_update`, `entity_delete`, `env_update`, `env_get`, `rag_query`, `rag_add`, `schedule_trigger`, `advance_time`, `calc`, `run_subagent`, `update_plot_rails`, `random_choice`, `submit_plan`, `submit_step` | +| **Interaction tools** | В `world_builder` / `world_editor` диалогах. Коммуницируют с игроком. | `ask_user`, `propose_changes`, `comment_to_user` | +| **Schema tools** | В `world_editor` для редактирования схем. | `schema_add_field`, `schema_remove_field`, `schema_modify_field`, `schema_add_type` | + +### 8.1. Общий формат ответа инструмента + +Все инструменты возвращают JSON-объект с одинаковой структурой: + +```json +{ + "ok": true, + "data": { ... }, + "message": "человекочитаемое описание для LLM" +} +``` + +или в случае ошибки: + +```json +{ + "ok": false, + "error": { + "code": "validation_error", + "message": "player.stats.health must be >= 0, got -5" + } +} +``` + +Этот формат возвращается LLM как `tool_result` сообщение. LLM видит и `message` (для самокоррекции), и `data` (для использования в следующих шагах). + +### 8.2. Game tools — детальные сигнатуры + +#### 8.2.1. `entity_create` + +Создаёт новую сущность в мире. + +```json +{ + "name": "entity_create", + "description": "Создаёт новую сущность в текущем мире. Тип должен существовать в world.schemas. data должна соответствовать schema этого типа.", + "parameters": { + "type": "object", + "required": ["entity_type", "name", "data"], + "properties": { + "entity_type": { + "type": "string", + "description": "Тип сущности из world.schemas (character, item, location, faction, ...)" + }, + "name": { + "type": "string", + "description": "Имя/название сущности (уникально в пределах (world_id, entity_type))" + }, + "data": { + "type": "object", + "description": "Полные данные сущности по schema" + }, + "add_to_environment": { + "type": "boolean", + "default": false, + "description": "Добавить ли сущность в environment (для быстрого доступа LLM)" + } + } + } +} +``` + +**Возвращает:** +```json +{ "ok": true, "data": { "entity_id": "uuid" }, "message": "Создан character 'Элара'" } +``` + +**Ошибки:** `unknown_entity_type`, `name_conflict`, `schema_violation`, `world_not_found`. + +#### 8.2.2. `entity_get` + +Получает сущность по ID или по (entity_type, name). + +```json +{ + "name": "entity_get", + "parameters": { + "required": ["query"], + "properties": { + "query": { + "oneOf": [ + { "type": "string", "description": "entity_id (UUID)" }, + { + "type": "object", + "properties": { + "entity_type": { "type": "string" }, + "name": { "type": "string" } + }, + "required": ["entity_type", "name"] + } + ] + } + } + } +} +``` + +#### 8.2.3. `entity_list` + +Список сущностей с фильтром. + +```json +{ + "name": "entity_list", + "parameters": { + "properties": { + "entity_type": { "type": "string", "description": "Фильтр по типу. Если не указан — все типы" }, + "in_environment_only": { "type": "boolean", "default": false }, + "name_contains": { "type": "string" }, + "limit": { "type": "integer", "default": 50, "max": 200 } + } + } +} +``` + +#### 8.2.4. `entity_update` + +Обновляет поля сущности через JSON-patch. + +```json +{ + "name": "entity_update", + "parameters": { + "required": ["entity_id", "patch"], + "properties": { + "entity_id": { "type": "string" }, + "patch": { + "type": "object", + "description": "JSON-patch: {field_path: new_value} или {field_path: {op: 'inc', by: N}}", + "example": { "stats.health": { "op": "inc", "by": -10 }, "description": "Ранен" } + } + } + } +} +``` + +#### 8.2.5. `entity_delete` + +Soft delete. Помечает `deleted_at=now()`. + +```json +{ + "name": "entity_delete", + "parameters": { + "required": ["entity_id"], + "properties": { + "entity_id": { "type": "string" }, + "reason": { "type": "string", "description": "Почему удаляется (для лога)" } + } + } +} +``` + +#### 8.2.6. `env_update` + +**Ключевой инструмент** — мутирует environment через валидируемый patch. + +```json +{ + "name": "env_update", + "description": "Применяет JSON-patch к environment. Patch проходит через state_validator. Если валидация провалилась — patch не применяется, LLM получает ошибку и может попробовать ещё раз.", + "parameters": { + "required": ["patch"], + "properties": { + "patch": { + "type": "object", + "description": "Карта field_path -> new_value | {op, by}. Поддерживаемые op: 'inc', 'dec', 'set', 'append', 'remove'.", + "example": { + "player.stats.health": { "op": "inc", "by": -10 }, + "player.stats.mana": { "op": "inc", "by": -5 }, + "player.inventory": { "op": "append", "value": { "item_id": "sword_01", "qty": 1 } } + } + } + } + } +} +``` + +**Возвращает:** +```json +{ + "ok": true, + "data": { "applied_paths": ["player.stats.health", "player.stats.mana"] }, + "message": "Environment обновлён" +} +``` + +#### 8.2.7. `env_get` + +Возвращает текущее значение поля environment (или весь environment если `path` не указан). + +```json +{ + "name": "env_get", + "parameters": { + "properties": { + "path": { "type": "string", "description": "Например 'player.stats' или 'plot_rails.current_goals'" } + } + } +} +``` + +#### 8.2.8. `update_plot_rails` + +Специализированный инструмент для управления сюжетными рельсами. + +```json +{ + "name": "update_plot_rails", + "parameters": { + "required": ["operation"], + "properties": { + "operation": { + "type": "string", + "enum": ["add_hook", "remove_hook", "add_goal", "remove_goal", "complete_goal"] + }, + "value": { "type": "string", "description": "Текст хука/цели" }, + "index": { "type": "integer", "description": "Для remove_* операций" } + } + } +} +``` + +#### 8.2.9. `rag_query` + +Семантический поиск по `story_entries` и `entities` через Qdrant. Сначала векторы запроса и документов сравниваются в Qdrant (с payload-фильтром по `world_id`), затем полные данные подтягиваются из PostgreSQL по IDs (см. §11.1.4). + +```json +{ + "name": "rag_query", + "description": "Semantic search over entities and story entries. Use when you need to recall past details, NPC names, world facts, or find an entity by description. Do NOT try to recall from memory — you may hallucinate.", + "parameters": { + "required": ["query"], + "properties": { + "query": { "type": "string", "description": "Natural-language search query." }, + "limit": { "type": "integer", "default": 5, "max": 20 }, + "filter_type": { "type": "string", "enum": ["all", "entities", "story_entries"], "default": "all" }, + "min_score": { "type": "number", "default": 0.7, "description": "Minimum cosine similarity (0..1)." } + } + } +} +``` + +**Возвращает:** +```json +{ + "ok": true, + "data": { + "results": [ + { "type": "entity", "id": "uuid", "score": 0.89, "content": { "entity_type": "character", "name": "...", "data": {...} } }, + { "type": "story_entry", "id": "uuid", "score": 0.82, "content": { "text": "Игрок убил дракона в день 3" } } + ] + } +} +``` + +#### 8.2.10. `rag_add` + +Добавляет факт в `story_entries` и индексирует его в Qdrant-коллекции `story_entries` (см. §11.1.5). Если embeddings API недоступен — запись сохраняется с `embedding_status='pending'`, фоновый индексатор досчитает вектор позже. + +```json +{ + "name": "rag_add", + "description": "Persist a fact/event as a story entry and index it for semantic search. Use when the player learns a new persistent fact (NPC secret, world lore, quest outcome) that should be recallable later via rag_query.", + "parameters": { + "required": ["content", "entry_type"], + "properties": { + "content": { "type": "string", "description": "Fact text. Will be truncated to 4000 chars before embedding." }, + "entry_type": { "type": "string", "enum": ["fact", "event", "relationship", "secret"] }, + "metadata": { "type": "object", "description": "Links: entity_id, step_id, etc." } + } + } +} +``` + +#### 8.2.11. `schedule_trigger` + +Планирует отложенное событие. + +```json +{ + "name": "schedule_trigger", + "description": "Планирует событие на игровое время. Сработает автоматически когда current_time >= fire_at.", + "parameters": { + "required": ["fire_at", "event_type", "payload"], + "properties": { + "fire_at": { "type": "string", "description": "Формат: [year_Y_]day_D_hour_H[_min_M]. Пример: 'day_5_hour_12'" }, + "event_type": { "type": "string", "enum": ["spawn_enemy", "weather_change", "quest_update", "npc_action", "custom"] }, + "payload": { "type": "object", "description": "Данные события. Структура зависит от event_type." } + } + } +} +``` + +#### 8.2.12. `advance_time` + +Принудительное продвижение времени (альтернатива — `submit_step` с полем `delta_time`). + +```json +{ + "name": "advance_time", + "parameters": { + "required": ["delta"], + "properties": { + "delta": { "type": "string", "description": "Формат: [year_Y][days_D][hours_H][min_M]. Пример: 'hours_2_min_30'" } + } + } +} +``` + +#### 8.2.13. `calc` + +Калькулятор для боевых и механических вычислений. Не позволяет LLM ошибиться в арифметике. + +```json +{ + "name": "calc", + "description": "Вычисляет математическое выражение. Используй для боёв, бросков кубиков, расчёта урона.", + "parameters": { + "required": ["expression"], + "properties": { + "expression": { + "type": "string", + "description": "Выражение в безопасном DSL. Поддерживаются +, -, *, /, %, d (бросок кубика: 2d6+3), min(), max(), round()", + "example": "max(1, 2d6+3 - enemy.armor)" + }, + "variables": { + "type": "object", + "description": "Контекст для подстановки переменных", + "example": { "enemy.armor": 5 } + } + } + } +} +``` + +**Возвращает:** `{ "ok": true, "data": { "result": 8, "rolls": [3, 5], "trace": "max(1, 8-5)=3" } }` + +#### 8.2.14. `random_choice` + +Детерминированный (с seed) выбор из вариантов. Seed = `hash(world_id + step_id + choice_index)` для воспроизводимости. + +```json +{ + "name": "random_choice", + "parameters": { + "required": ["options"], + "properties": { + "options": { "type": "array", "items": {}, "minItems": 2 }, + "weights": { "type": "array", "items": { "type": "number" } } + } + } +} +``` + +#### 8.2.15. `run_subagent` + +Запускает вложенный LLM-вызов для офэкранных действий (Phase 3.1). + +```json +{ + "name": "run_subagent", + "description": "Запускает вложенный LLM-вызов с собственным промптом для офэкранных действий (например, что происходит в соседней комнате пока игрок здесь).", + "parameters": { + "required": ["task", "tools"], + "properties": { + "task": { "type": "string", "description": "Описание задачи для subagent" }, + "tools": { + "type": "array", + "items": { "type": "string" }, + "description": "Список имён инструментов, доступных subagent" + }, + "context": { + "type": "object", + "description": "Дополнительный контекст (entity_id, location, ...)" + }, + "max_iterations": { "type": "integer", "default": 5, "max": 10 } + } + } +} +``` + +#### 8.2.16. `submit_plan` (Phase 1 завершение) + +Завершает Phase 1, передаёт план действий в Phase 2. + +```json +{ + "name": "submit_plan", + "description": "Завершает Phase 1 планирования. Передаёт план и краткую сводку действий в Phase 2 (writer).", + "parameters": { + "required": ["plan", "summary"], + "properties": { + "plan": { + "type": "string", + "description": "Что произошло в этой итерации (для writer)" + }, + "summary": { + "type": "array", + "items": { "type": "object" }, + "description": "Краткая сводка вызванных инструментов: [{tool, result_summary}]", + "example": [ + { "tool": "entity_create", "result_summary": "Создан враг 'Гоблин'" }, + { "tool": "env_update", "result_summary": "player.stats.health -10" } + ] + }, + "offscreen_events": { + "type": "array", + "items": { "type": "string" }, + "description": "Заэкранные события для subagent в Phase 3.1" + } + } + } +} +``` + +#### 8.2.17. `submit_step` (Phase 2 завершение) + +Финальный инструмент Phase 2 — writer возвращает сцену. + +```json +{ + "name": "submit_step", + "description": "Завершает Phase 2. Writer возвращает финальный нарратив и дельту времени.", + "parameters": { + "required": ["scene_text", "delta_time"], + "properties": { + "scene_text": { + "type": "string", + "description": "Нарратив итерации. Минимум 100 символов, максимум 4000." + }, + "delta_time": { + "type": "string", + "description": "Сколько игрового времени заняла итерация. Формат: [year_Y][days_D][hours_H][min_M]." + } + } + } +} +``` + +#### 8.2.18. `suggest_actions` (Phase 3.2 завершение) + +Генерирует 1-3 следующих действия для игрока. + +```json +{ + "name": "suggest_actions", + "parameters": { + "required": ["actions"], + "properties": { + "actions": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "maxItems": 3, + "description": "1-3 коротких действия на языке мира" + } + } + } +} +``` + +### 8.3. Interaction tools + +#### 8.3.1. `ask_user` + +Запрос уточнения у игрока в world_builder / world_editor. + +```json +{ + "name": "ask_user", + "parameters": { + "required": ["question"], + "properties": { + "question": { "type": "string" }, + "options": { + "type": "array", + "items": { "type": "string" }, + "description": "Опциональные варианты ответа" + }, + "allow_free_text": { "type": "boolean", "default": true } + } + } +} +``` + +**Блокирует** поток до ответа игрока (через SSE `clarification` event + REST `/api/sessions/.../answer`). + +#### 8.3.2. `propose_changes` (world_editor) + +Предлагает изменения игроку на accept/reject. + +```json +{ + "name": "propose_changes", + "parameters": { + "required": ["diff"], + "properties": { + "diff": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { "type": "string", "example": "schemas[1].properties[2].verbose" }, + "op": { "type": "string", "enum": ["add", "remove", "replace"] }, + "old": {}, + "new": {} + } + } + }, + "comment": { "type": "string", "description": "Почему эти изменения" } + } + } +} +``` + +#### 8.3.3. `comment_to_user` + +Просто текстовый комментарий в чат (не требует ответа). + +```json +{ + "name": "comment_to_user", + "parameters": { + "required": ["text"], + "properties": { "text": { "type": "string" } } + } +} +``` + +### 8.4. Schema tools (для world_editor) + +#### 8.4.1. `schema_add_type` + +Добавляет новый тип сущности в `world.schemas`. + +```json +{ + "name": "schema_add_type", + "parameters": { + "required": ["type", "verbose", "plural", "properties"], + "properties": { + "type": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "verbose": { "type": "string" }, + "plural": { "type": "string" }, + "properties": { "type": "array", "items": { /* property definition */ } } + } + } +} +``` + +#### 8.4.2. `schema_add_field`, `schema_remove_field`, `schema_modify_field` + +Аналогично — для редактирования `properties` существующих типов. + +### 8.5. Реестр инструментов + +Все инструменты регистрируются в `app/engine/tools/registry.py`: + +```python +from app.engine.tools.base import Tool, ToolContext, ToolResult + +class ToolRegistry: + def __init__(self): + self._tools: dict[str, Tool] = {} + + def register(self, tool: Tool) -> None: + self._tools[tool.name] = tool + + def get(self, name: str) -> Tool | None: + return self._tools.get(name) + + def list_for_stage(self, stage: str) -> list[Tool]: + """Возвращает инструменты, доступные на данной стадии.""" + ... + + def to_openai_format(self, stage: str) -> list[dict]: + """Сериализует в OpenAI tools format.""" + ... +``` + +**Доступность инструментов по стадиям:** + +| Stage | Доступные инструменты | +|---|---| +| `world_builder` | `ask_user`, `comment_to_user`, `schema_add_type`, `schema_add_field`, `entity_create`, `env_update`, `submit_plan` | +| `world_editor` | `ask_user`, `comment_to_user`, `propose_changes`, `schema_*`, `entity_*`, `env_update`, `rag_query` | +| `orchestrator_phase1` | Все game tools, кроме `submit_step` и `suggest_actions` | +| `orchestrator_phase2` | `submit_step` (единственный) | +| `orchestrator_phase3_summary` | `submit_summary` | +| `orchestrator_phase3_suggest` | `suggest_actions` (единственный) | +| `intro_scene` | `submit_step`, `suggest_actions` | +| `subagent` | `entity_*`, `env_get`, `env_update`, `rag_query` | + +### 8.6. Контракт выполнения + +Каждый tool вызывается через `ToolRegistry.execute()`: + +```python +async def execute( + self, + name: str, + arguments: dict, + ctx: ToolContext, +) -> ToolResult: + """ + ctx содержит: world, session, user_id, step_id, stage, sse_emitter. + Возвращает ToolResult с ok/data/message или ok=false/error. + Логирует вызов в step_tool_calls. + Эмитит SSE событие tool_call. + """ +``` + +--- + +## 9. Потоки (flows) с sequence-диаграммами + +В системе семь ключевых потоков. Каждый поток имеет свой system-промпт, свой набор инструментов и свой SSE-протокол. **Каждый поток изолирован** — сообщения одного потока не видны другому, чтобы предотвратить галлюцинации. + +### 9.1. Поток создания мира (world_builder) + +Запускается при `POST /api/worlds`. Создаёт новый мир из пресета или формы, генерирует схемы, environment, начальные сущности и вступительную сцену. + +**Шаги:** +1. Получение шаблона мира (built-in / опубликованный / form). +2. Игрок заполняет: название, язык, своего персонажа, заметки. +3. Первоначальная генерация мира (LLM генерирует `schemas`, `environment_schema`, `environment` с `player`). +4. Редактирование (через `world_editor` flow). +5. Догенерация начального состояния (локации, персонажи, предметы, цели). +6. Генерация вступительной сцены + 1-3 действий. +7. Игрок нажимает "Начать игру" → переход на страницу мира. + +```mermaid +sequenceDiagram + autonumber + participant U as Игрок + participant FE as Frontend + participant API as FastAPI + participant WB as WorldBuilder + participant LLM as LLM + participant DB as PostgreSQL + + U->>FE: Заполняет форму / выбирает пресет + FE->>API: POST /api/worlds {mode, preset_id|form_data, name, language, player_name, notes} + API->>DB: INSERT world (status=draft) + API-->>FE: 202 {world_id, stream_url} + FE->>API: GET /api/sessions/worlds/{id}/builder/stream (SSE) + + Note over WB: Шаг 1: Генерация схем + WB->>LLM: prompt(world_builder_schema) + form_data + LLM-->>WB: {schemas, environment_schema, rules, time_schema} + WB->>DB: UPDATE world SET schemas=..., environment_schema=... + WB-->>FE: SSE: world_schema_generated + + Note over WB: Шаг 2: Генерация environment (player) + WB->>LLM: prompt(world_builder_env) + schemas + player_name + LLM-->>WB: {environment: {player, current_location, plot_rails}} + WB->>DB: UPDATE world SET environment=..., plot_rails=... + WB-->>FE: SSE: environment_generated + + Note over WB: Шаг 3: Догенерация сущностей + WB->>LLM: prompt(world_builder_entities) + environment + LLM->>WB: tool_call(entity_create, location "Таверна") + WB->>DB: INSERT entity + WB-->>LLM: tool_result(ok) + LLM->>WB: tool_call(entity_create, character "Трактирщик") + WB->>DB: INSERT entity + WB-->>LLM: tool_result(ok) + LLM->>WB: tool_call(submit_plan) + WB-->>FE: SSE: entities_generated + + Note over WB: Шаг 4: Генерация вступительной сцены + WB->>LLM: prompt(intro_scene) + environment + plot_rails + LLM-->>WB: tool_call(submit_step, {scene_text, delta_time}) + WB-->>FE: SSE: intro_scene_chunk (streaming) + WB->>LLM: prompt(suggest_actions) + LLM-->>WB: tool_call(suggest_actions, {actions: [...]}) + WB->>DB: UPDATE world SET intro_scene=..., status='ready' + WB-->>FE: SSE: intro_scene_complete + done + FE->>U: Показывает сцену + кнопку "Начать игру" +``` + +**Edge-cases:** +- LLM вернул невалидный schema → `state_validator` возвращает ошибку → LLM получает `tool_result` с ошибкой → пробует ещё раз (до 3 попыток). Если не вышло — SSE `error` с кодом `schema_generation_failed`. +- LLM не вызвала `submit_plan` за `max_substeps` → WB форсит завершение с `submit_plan` от последнего состояния. +- Игрок закрыл вкладку → мир остаётся в `status=draft`, доступен в списке миров с пометкой "Не завершён". + +### 9.2. Поток редактирования мира (world_editor) + +Запускается при `POST /api/worlds/{id}/edit`. Игрок даёт текстовую инструкцию, LLM предлагает изменения, игрок принимает/отклоняет. + +```mermaid +sequenceDiagram + autonumber + participant U as Игрок + participant FE as Frontend + participant API as FastAPI + participant WE as WorldEditor + participant LLM as LLM + participant DB as PostgreSQL + + U->>FE: Открывает страницу редактирования + FE->>API: GET /api/worlds/{id} + API-->>FE: Полные данные мира + FE->>U: Показывает JSON + чат + + U->>FE: Пишет инструкцию "Добавь меч в инвентарь" + FE->>API: POST /api/worlds/{id}/edit {instruction} + API-->>FE: 202 {stream_url} + FE->>API: GET .../editor/stream (SSE) + + WE->>LLM: prompt(world_editor) + world_state + instruction + LLM->>WE: tool_call(ask_user, "Какой меч: короткий или длинный?") + WE-->>FE: SSE: clarification + FE->>U: Показывает вопрос + U->>FE: Отвечает "Короткий" + FE->>API: POST .../answer {text: "Короткий"} + API->>WE: deliver answer + WE->>LLM: tool_result("Короткий") + + LLM->>WE: tool_call(entity_create, item "Короткий меч") + WE->>DB: (в staging-транзакции) INSERT entity + LLM->>WE: tool_call(propose_changes, {diff, comment}) + WE-->>FE: SSE: change_proposed + comment + + U->>FE: Принимает изменения + FE->>API: POST .../apply + API->>WE: commit staging + WE->>DB: COMMIT + WE-->>FE: SSE: apply_changes + done + + U->>FE: Нажимает "Сохранить" + FE->>API: PATCH /api/worlds/{id} (если нужны ещё правки) +``` + +**Edge-cases:** +- Игрок отклонил изменения → staging-транзакция rollback, LLM получает `tool_result(discard)` и может предложить альтернативу. +- Игрок дал неоднозначную инструкцию → LLM использует `ask_user` для уточнения. Если игрок не отвечает 60 секунд → SSE `warning` "Ожидание ответа". +- Игрок вручную правит JSON параллельно с LLM-итерацией → optimistic lock через `updated_at`: при PATCH сервер проверяет, что `updated_at` не изменился; если изменился — `409 state_conflict`. + +### 9.3. Поток итерации сессии (orchestrator) + +**Самый сложный поток.** Запускается при `POST /api/sessions/worlds/{id}/iterate`. Три фазы + подфазы для deferred triggers и summary. + +#### 9.3.1. Общая sequence-диаграмма + +```mermaid +sequenceDiagram + autonumber + participant U as Игрок + participant FE as Frontend + participant API as FastAPI + participant ORC as Orchestrator + participant LLM as LLM + participant TOOLS as ToolRegistry + participant DB as PostgreSQL + participant SUB as Subagent + participant TR as TriggerChecker + + U->>FE: Выбирает действие (custom или suggested) + FE->>API: POST /api/sessions/worlds/{id}/iterate {action} + API->>DB: INSERT step (sequence_number, player_action) + API-->>FE: 202 {stream_url, step_id} + FE->>API: GET .../iterate/stream (SSE) + + Note over ORC: === Phase 1: Planner + Executor === + ORC-->>FE: SSE: phase_start {phase:1} + loop До max_substeps или submit_plan + ORC->>LLM: prompt(orchestrator_phase1) + context + LLM->>TOOLS: tool_call(env_update / entity_* / calc / ...) + TOOLS->>DB: mutate state + TOOLS-->>LLM: tool_result + TOOLS-->>FE: SSE: tool_call + LLM-->>ORC: response (continue or submit_plan) + end + ORC-->>FE: SSE: phase_end {phase:1} + + Note over ORC: === Phase 2: Writer === + ORC-->>FE: SSE: phase_start {phase:2} + ORC->>LLM: prompt(orchestrator_phase2) + plan + summary + LLM-->>ORC: tool_call(submit_step, {scene_text, delta_time}) + ORC-->>FE: SSE: scene_chunk (streaming) + ORC-->>FE: SSE: scene_complete + ORC-->>FE: SSE: phase_end {phase:2} + + Note over ORC: === Phase 3: Persist + Triggers + Summary + Suggest === + ORC-->>FE: SSE: phase_start {phase:3} + ORC->>DB: COMMIT env + entities + step.scene_text + ORC->>DB: UPDATE world.current_time += delta_time + + Note over ORC,TR: 3.1: Deferred triggers + TR->>DB: SELECT * FROM deferred_triggers WHERE fire_at <= current_time AND is_fired=false + loop Для каждого trigger + ORC->>SUB: run_subagent(task, tools) + SUB->>LLM: prompt(subagent) + trigger.payload + LLM->>TOOLS: tool_call(...) + TOOLS->>DB: mutate state + SUB->>LLM: prompt(subagent_summary) + LLM-->>SUB: short_text + SUB-->>ORC: {offscreen_summary, state_changes} + ORC-->>FE: SSE: trigger_fired + ORC->>DB: UPDATE trigger SET is_fired=true, fired_at=now() + end + + Note over ORC: 3.2: Summary (если нужно) + ORC->>DB: SELECT count(*) FROM steps WHERE world_id=... + alt count > compression_threshold + ORC->>LLM: prompt(summary) + old_messages + LLM-->>ORC: summary_text + ORC->>DB: INSERT story_entry (type=event, content=summary_text) + ORC-->>FE: SSE: summary_generated + end + + Note over ORC: 3.3: Suggest actions + ORC->>LLM: prompt(suggest_actions) + recent_scene + LLM-->>ORC: tool_call(suggest_actions, {actions: [...]}) + ORC->>DB: UPDATE step SET suggested_actions=... + ORC-->>FE: SSE: suggested_actions + + ORC-->>FE: SSE: iteration_complete + done + FE->>U: Показывает сцену + новые действия +``` + +#### 9.3.2. Phase 1 — детально + +Phase 1 — циклическая. У неё есть лимит `game.max_substeps_per_iteration` (default 8). Каждый цикл: + +1. ORC собирает контекст: system prompt + environment + recent_steps (с учётом summary) + player_action. +2. LLM получает контекст и массив доступных tools (game tools + `submit_plan`). +3. LLM может вызвать несколько tools в одном response (parallel tool calls). +4. ORC исполняет tools, эмитит SSE `tool_call`, добавляет `tool_result` в messages. +5. LLM продолжает, пока не вызовет `submit_plan` или не превысит лимит. + +**Параметры LLM:** `temperature=0.7`, `top_p=0.9`, `max_tokens=2048`. + +**Edge-cases Phase 1:** +- LLM вызвала tool, который вернул `ok=false` → LLM получает `tool_result` с ошибкой, может исправиться. +- LLM вызвала unknown tool → ORC возвращает `tool_result(ok=false, code=unknown_tool)`. +- LLM не вызвала `submit_plan` за лимит → ORC форсит завершение: собирает summary из последних tool_results, формирует `submit_plan` автоматически. +- LLM вернула `finish_reason=length` (уперлась в `max_tokens`) → ORC увеличивает `max_tokens` до 4096 и ретраит. Если снова length — форсит завершение. + +#### 9.3.3. Phase 2 — детально + +Phase 2 — один LLM-вызов с одним tool `submit_step`. + +1. ORC собирает компактный контекст: system prompt (writer) + plan + summary из Phase 1 + environment (snapshot). +2. LLM вызывает `submit_step(scene_text, delta_time)`. +3. ORC стримит `scene_text` во фронтенд через SSE `scene_chunk`. +4. После завершения — `scene_complete` с полным текстом и `delta_time`. + +**Параметры LLM:** `temperature=0.85`, `top_p=0.95`, `max_tokens=2048`. + +**Edge-cases Phase 2:** +- LLM не вызвала `submit_step` → ORC ретраит с явным указанием "MUST call submit_step". После 2 ретраев — `error` с кодом `writer_no_submit`. +- `scene_text` < 100 символов → ORC отклоняет, просит LLM расписать подробнее. +- `delta_time` не парсится → ORC использует дефолт `hours_1`. + +#### 9.3.4. Phase 3 — детально + +Phase 3 — детерминированная (без LLM, кроме подфаз 3.1/3.2/3.3). + +**3.0 Persist:** +- COMMIT основной транзакции: `environment`, `entities`, `step.scene_text`, `step.scene_delta_time`, `step.tool_calls_summary`. +- `world.current_time = advance_world_time(world.current_time, delta_time, world.time_schema)`. + +**3.1 Deferred triggers (если `game.deferred_triggers_enabled=true`):** + +```python +triggers = await db.execute( + select(DeferredTrigger) + .where( + DeferredTrigger.world_id == world_id, + DeferredTrigger.is_fired == False, + DeferredTrigger.fire_at <= world.current_time, + ) +) +for trigger in triggers: + subagent_result = await run_subagent( + task=f"Process deferred trigger: {trigger.event_type}", + context=trigger.payload, + tools=["entity_create", "entity_update", "env_update", "rag_add"], + max_iterations=5, + ) + # subagent_result.offscreen_summary добавляется к scene_text + step.scene_text += "\n\n" + subagent_result.offscreen_summary + trigger.is_fired = True + trigger.fired_at = now() + await db.commit() +``` + +**3.2 Summary (если нужно):** + +```python +recent_steps = await get_recent_steps(world_id, limit=compression_threshold + 1) +if len(recent_steps) > compression_threshold: + old_messages = [s.to_llm_message() for s in recent_steps[guaranteed:]] + summary = await llm.complete( + prompt=get_prompt("summary", world.language), + messages=old_messages, + temperature=0.3, + ) + await db.insert(StoryEntry( + world_id=world_id, + content=summary, + entry_type="event", + metadata={"type": "summary", "step_range": [first_seq, last_seq]}, + )) +``` + +**3.3 Suggest actions:** + +```python +suggestions = await llm.complete( + prompt=get_prompt("suggest_actions", world.language), + messages=[recent_scene_message], + tools=[suggest_actions_tool], + temperature=0.8, +) +step.suggested_actions = suggestions.actions +``` + +### 9.4. Поток инициализации сервера + +Запускается при старте приложения (`lifespan` handler в FastAPI). + +```mermaid +sequenceDiagram + participant Uvicorn + participant App as FastAPI + participant DB as PostgreSQL + participant Settings as settings_service + + Uvicorn->>App: startup + App->>DB: CREATE EXTENSION IF NOT EXISTS vector + App->>DB: alembic upgrade head + App->>Settings: load_defaults_from_env() + Settings->>DB: UPSERT settings FROM .env + App->>Settings: ensure_admin_setup_token() + Settings-->>App: token (from DB or generated) + App->>App: print("Admin setup URL: /register/admin?token=...") + App->>DB: SELECT COUNT(*) FROM users WHERE is_admin=true + alt no admins + App->>App: print("WARN: no admins yet, use setup URL") + end +``` + +### 9.5. Поток создания администратора + +При каждом запуске сервер выводит в лог URL вида `/register/admin?token=`. Токен берётся из `.env` (если задан) или генерируется случайный и сохраняется в `settings`. + +```mermaid +sequenceDiagram + participant Admin as Будущий админ + participant FE as Frontend + participant API as FastAPI + + Admin->>FE: Открывает /register/admin?token=XXX + FE->>API: POST /api/register/admin {token, email, username, password} + API->>API: validate token == settings['admin.setup_token'] + alt token valid + API->>API: create user (is_admin=true) + API-->>FE: 201 {user} + FE->>Admin: Редирект на /login + else token invalid + API-->>FE: 403 invalid_admin_token + end +``` + +**Edge-cases:** +- Если в БД уже есть админ → регистрация по admin-URL блокируется (403 `admin_already_exists`), даже с верным токеном. +- Токен ротируется каждые 24 часа (cron task). + +### 9.6. Регистрация пользователя + +Обычная регистрация. Доступна только если в БД уже есть хотя бы один админ (иначе система "закрыта" до создания первого админа). + +**Валидации:** +- `email`: RFC-совместимый, не длиннее 255 символов, уникальный. +- `username`: 3-64 символа, `[a-zA-Z0-9_]`, уникальный. +- `password`: ≥ 8 символов, минимум 1 буква и 1 цифра, не в blacklist (top-1000 утечек). +- `password_confirm`: должен совпадать с `password`. + +### 9.7. Вход в систему + +Логин по `email` ИЛИ `username` (автоопределение по наличию `@`). JWT-токен с `expires_in=86400` (24 часа). Refresh-токен с `expires_in=604800` (7 дней). + +```mermaid +sequenceDiagram + participant U as Пользователь + participant FE as Frontend + participant API as FastAPI + participant DB as PostgreSQL + + U->>FE: Вводит login + password + FE->>API: POST /api/auth/login {login, password} + API->>DB: SELECT user WHERE email=$1 OR username=$1 + alt user found + API->>API: verify bcrypt(password, user.password_hash) + alt password correct + API->>API: generate JWT (sub=user_id, exp=now+24h) + API->>DB: UPDATE user.last_login_at=now() + API-->>FE: 200 {access_token, refresh_token, user} + FE->>FE: store token in authStore + FE->>U: redirect to /worlds + else password wrong + API-->>FE: 401 invalid_credentials + end + else user not found + API-->>FE: 401 invalid_credentials + end +``` + +**Anti-enumeration:** ответ на "неверный пароль" и "пользователь не найден" одинаковый (`401 invalid_credentials`), чтобы не давать информации для перебора. + +--- + +## 10. Промпт-шаблоны и контекстный менеджер + +### 10.1. Структура промптов + +Все системные промпты лежат в `app/prompts/` как Python-строки с `str.format()` интерполяцией. Единственная точка доступа — функция `get_prompt(stage, language)`: + +```python +# app/prompts/__init__.py +from app.prompts.registry import get_prompt + +system_prompt = get_prompt("orchestrator_phase1", "ru").format( + world_name=world.name, + rules="\n".join(f"- {r}" for r in world.rules), + schemas_summary=summarize_schemas(world.schemas), + environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2), + current_time=world.current_time, + plot_rails_json=json.dumps(world.plot_rails, ensure_ascii=False, indent=2), +) +``` + +**Структура директории:** +``` +app/prompts/ +├── __init__.py +├── registry.py # get_prompt(stage, language) +├── stages/ +│ ├── world_builder_schema.py +│ ├── world_builder_env.py +│ ├── world_builder_entities.py +│ ├── world_editor.py +│ ├── orchestrator_phase1.py +│ ├── orchestrator_phase2.py +│ ├── orchestrator_phase3_summary.py +│ ├── orchestrator_phase3_suggest.py +│ ├── intro_scene.py +│ ├── subagent.py +│ └── summary.py +└── locales/ + ├── en.py + └── ru.py +``` + +**Соглашение:** каждый файл `stages/*.py` экспортирует dict `PROMPTS = {"en": "...", "ru": "..."}`. `registry.py` собирает их в один большой dict по ключу `stage`. + +> **⚠️ Языковой правило для LLM-промптов (критично):** Все промпты и инструкции для LLM (system-сообщения, описания инструментов в JSON-schema, runtime-инструкции, сводные блоки типа `rules` / `schemas_summary` / `environment_json`) **должны быть на английском** — даже если `world.language = 'ru'` и игровой нарратив генерируется на русском. Английские промпты дают существенно более высокое качество для современных LLM (особенно локальных 7B-32B моделей): меньше галлюцинаций, точнее tool-calling, лучше следование формату. +> +> **Разделение:** +> - **Промпт (внутренний)** — английский. Хранится в `app/prompts/stages/*.py` под ключом `"en"`. Ключ `"ru"` в PROMPTS оставлен только для legacy-сценариев и в новой разработке НЕ используется. +> - **Нарратив (внешний, для игрока)** — язык мира `world.language`. Phase 2 writer инструктируется английским промптом, но генерирует текст на языке мира (`«Write the scene in {language}»`). +> - **UI-строки фронтенда** — язык интерфейса пользователя (`uiStore.language`), через i18n. +> +> Это правило обязательно для всех stage-промптов. Если в существующем промпте есть русский — это баг, нужно перевести. + +### 10.2. Шаблон system-промпта для orchestrator_phase1 + +```python +# app/prompts/stages/orchestrator_phase1.py +PROMPTS = { + "ru": """Ты — Game Master (GM) текстовой ролевой игры в мире "{world_name}". # LEGACY: не используется, см. языковое правило §10.1 +... +""", + "en": """You are the Game Master (GM) of a text RPG in the world "{world_name}". + +# Your responsibilities +1. Evaluate the player's action and decide what happened mechanically. +2. Call tools for ANY state change in the world. +3. Do NOT write free narrative — the writer will do that in Phase 2. +4. End Phase 1 by calling submit_plan with the plan and action summary. + +# World rules +{rules} + +# Entity schemas +{schemas_summary} + +# Current environment +{environment_json} + +# Plot rails +{plot_rails_json} + +# Current time +{current_time} + +# Available tools +You can call: entity_create, entity_get, entity_list, entity_update, entity_delete, +env_update, env_get, rag_query, rag_add, schedule_trigger, advance_time, calc, random_choice, +run_subagent, update_plot_rails, submit_plan. + +# Hard rules +- ANY state change goes through a tool call. Do NOT write "you took damage" in the text. +- After each tool call you receive a tool_result. Check ok=true. +- If ok=false — fix the arguments and try again. +- Use calc for dice rolls and arithmetic. Do NOT compute in your head. +- After max {max_substeps} steps you MUST call submit_plan. +""" +} +``` + +### 10.3. Контекстный менеджер истории + +LLM-контекст ограничен (8K–32K токенов в зависимости от модели). В долгих сессиях нельзя передавать всю историю. Стратегия: последние N сообщений + опциональный summary. + +**Логика (реализована в `app/engine/context.py`):** + +```python +async def build_context( + world: World, + step: Step, + recent_steps: list[Step], + settings: Settings, +) -> list[dict]: + """ + Возвращает массив messages для LLM. + """ + guaranteed = settings["context.guaranteed_messages"] # default 10 + threshold = settings["context.compression_threshold_messages"] # default 20 + + messages: list[dict] = [] + + # 1. System prompt + messages.append({"role": "system", "content": system_prompt}) + + # 2. Если история длинная — добавляем summary в начале + if len(recent_steps) > threshold: + summary = await get_latest_summary(world.id) + if summary: + messages.append({ + "role": "system", + "content": f"Сводка прошлых событий:\n{summary.content}" + }) + # Берём только последние guaranteed шагей + recent_steps = recent_steps[-guaranteed:] + + # 3. Последние шаги как user/assistant messages + for s in recent_steps: + messages.append({"role": "user", "content": s.player_action}) + messages.append({"role": "assistant", "content": s.scene_text}) + + # 4. Текущее действие игрока + messages.append({"role": "user", "content": step.player_action}) + + return messages +``` + +**Когда генерируется summary:** +- При каждом Phase 3 orchestrator проверяет: `len(recent_steps) > threshold` (с учётом уже существующих summaries). +- Если да — вызывает LLM с `prompt(summary)` на сообщения `[guaranteed:last]`. +- Создаёт `StoryEntry` с `entry_type="event"`, `metadata={"type": "summary", "step_range": [...]}`. +- Старые сообщения (кроме guaranteed) **не удаляются** из БД — они просто не попадают в контекст LLM. Полная история доступна через API. + +**Параметры LLM для summary:** `temperature=0.3` (низкая — для точности), `max_tokens=1024`. + +### 10.4. Параметры LLM по стадиям + +| Stage | temperature | top_p | max_tokens | Stream | +|---|---|---|---|---| +| `world_builder_schema` | 0.5 | 0.9 | 4096 | no | +| `world_builder_env` | 0.6 | 0.9 | 2048 | no | +| `world_builder_entities` | 0.7 | 0.9 | 2048 | no | +| `world_editor` | 0.5 | 0.9 | 2048 | no | +| `orchestrator_phase1` | 0.7 | 0.9 | 2048 | no | +| `orchestrator_phase2` | 0.85 | 0.95 | 2048 | yes (scene_chunk) | +| `orchestrator_phase3_summary` | 0.3 | 0.9 | 1024 | no | +| `orchestrator_phase3_suggest` | 0.8 | 0.95 | 512 | no | +| `intro_scene` | 0.85 | 0.95 | 2048 | yes | +| `subagent` | 0.6 | 0.9 | 2048 | no | +| `summary` | 0.3 | 0.9 | 1024 | no | + +--- + +## 11. RAG-подсистема и валидация состояния + +### 11.1. RAG через Qdrant + +Архитектура RAG-подсистемы построена на **разделении хранения**: PostgreSQL хранит текст и метаданные, Qdrant — только векторы. Это даёт несколько преимуществ: + +- **Масштабируемость:** Qdrant оптимизирован для ANN-поиска (HNSW) и не нагружает PostgreSQL векторными индексами. +- **Независимость:** можно менять embedding-модель без миграции реляционной схемы — достаточно пересоздать коллекцию и переиндексировать. +- **Изоляция миров:** каждая точка в Qdrant имеет payload `{world_id, entity_type, deleted_at, ...}`; фильтр по `world_id` гарантирует, что поиск в одном мире никогда не вернёт результаты другого. + +#### 11.1.1. Топология коллекций + +Две коллекции в одном Qdrant-инстансе (НЕ одна коллекция на мир — это усложнило бы администрирование): + +| Коллекция | Размерность | Distance | Payload-поля | Назначение | +|---|---|---|---|---| +| `entities` | из `settings.embeddings.dimension` | `Cosine` | `world_id`, `entity_id`, `entity_type`, `name`, `deleted` | Векторы сущностей (character, item, location, ...) | +| `story_entries` | из `settings.embeddings.dimension` | `Cosine` | `world_id`, `entry_id`, `entry_type`, `step_id`, `created_at` | Векторы сюжетных записей (fact, event, summary, ...) | + +ID точки в Qdrant = `str(uuid)` соответствующей записи в PostgreSQL. В PostgreSQL колонка `qdrant_point_id` хранит тот же UUID — это позволяет восстановить связь при переиндексации. + +> **Почему одна коллекция на тип, а не на мир?** Создание коллекции под каждый мир потребует N коллекций (по числу миров), что усложнит администрирование и резервное копирование. Фильтр по `world_id` в payload даёт ту же изоляцию с минимальным оверхедом — Qdrant индексирует payload-поля отдельно от векторов (payload index), поэтому фильтрация по `world_id` практически бесплатна. + +#### 11.1.2. Создание коллекций (startup-хук) + +`app/migrations/init_qdrant.py` запускается при старте приложения (после `alembic upgrade head`). Создаёт коллекции если их нет, и обязательные payload-индексы для быстрых фильтров: + +```python +# app/migrations/init_qdrant.py +from qdrant_client import AsyncQdrantClient +from qdrant_client.http.models import Distance, VectorParams, PayloadSchemaType + +async def init_qdrant_collections(dimension: int) -> None: + client = AsyncQdrantClient(url=settings.QDRANT_URL, api_key=settings.QDRANT_API_KEY) + existing = {c.name for c in (await client.get_collections()).collections} + + for name in ("entities", "story_entries"): + if name in existing: + continue + await client.create_collection( + collection_name=name, + vectors_config=VectorParams(size=dimension, distance=Distance.COSINE), + ) + await client.create_payload_index(name, "world_id", PayloadSchemaType.KEYWORD) + if name == "entities": + await client.create_payload_index(name, "entity_type", PayloadSchemaType.KEYWORD) + await client.create_payload_index(name, "deleted", PayloadSchemaType.BOOL) + else: + await client.create_payload_index(name, "entry_type", PayloadSchemaType.KEYWORD) + await client.create_payload_index(name, "created_at", PayloadSchemaType.INTEGER) +``` + +#### 11.1.3. Интерфейс Embedder + +```python +# app/core/embeddings.py +from typing import Protocol +import httpx + +class Embedder(Protocol): + async def embed(self, texts: list[str]) -> list[list[float]]: ... + @property + def dimension(self) -> int: ... + +class HashEmbedder: + """Offline-эмбеддер для dev/test. Bag-of-words + hash projection.""" + def __init__(self, dimension: int = 256): + self._dim = dimension + async def embed(self, texts: list[str]) -> list[list[float]]: + return [self._hash_project(t) for t in texts] + @property + def dimension(self) -> int: + return self._dim + +class OpenAIEmbedder: + """OpenAI-compatible embeddings API.""" + def __init__(self, api_url: str, api_key: str, model: str, dimension: int, timeout: float = 30.0): + self._api_url = api_url.rstrip("/") + self._api_key = api_key + self._model = model + self._dim = dimension + self._timeout = timeout + async def embed(self, texts: list[str]) -> list[list[float]]: + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.post( + f"{self._api_url}/embeddings", + headers={"Authorization": f"Bearer {self._api_key}"}, + json={"model": self._model, "input": texts}, + ) + resp.raise_for_status() + data = resp.json() + return [d["embedding"] for d in sorted(data["data"], key=lambda x: x["index"])] + @property + def dimension(self) -> int: + return self._dim +``` + +#### 11.1.4. Реализация rag_query (двухстадийный retrieval) + +Сначала ищем в Qdrant → получаем IDs + scores → затем по IDs подтягиваем полные данные из PostgreSQL. Это держит payload Qdrant маленьким, а полный текст — в реляционной БД. + +```python +# app/core/rag.py +from uuid import UUID +from qdrant_client import AsyncQdrantClient +from qdrant_client.http.models import Filter, FieldCondition, MatchValue + +async def rag_query( + world_id: UUID, + query: str, + limit: int = 5, + filter_type: str = "all", + min_score: float = 0.7, +) -> list[dict]: + """Семантический поиск по entities + story_entries через Qdrant.""" + embedder = get_embedder() + try: + query_vec = (await embedder.embed([query]))[0] + except Exception as e: + logger.warning("rag_query_embed_failed", error=str(e)) + return [] # embedder недоступен — возвращаем пусто, не роняем итерацию + + qdrant: AsyncQdrantClient = get_qdrant_client() + world_filter = FieldCondition(key="world_id", match=MatchValue(value=str(world_id))) + + results: list[dict] = [] + + if filter_type in ("all", "entities"): + ents = await qdrant.search( + collection_name="entities", + query_vector=query_vec, + query_filter=Filter(must=[world_filter, + FieldCondition(key="deleted", match=MatchValue(value=False))]), + limit=limit, + score_threshold=min_score, + with_payload=True, + ) + for p in ents: + results.append({"type": "entity", "id": p.payload["entity_id"], + "score": p.score, "name": p.payload.get("name"), + "entity_type": p.payload.get("entity_type")}) + + if filter_type in ("all", "story_entries"): + sts = await qdrant.search( + collection_name="story_entries", + query_vector=query_vec, + query_filter=Filter(must=[world_filter]), + limit=limit, + score_threshold=min_score, + with_payload=True, + ) + for p in sts: + results.append({"type": "story_entry", "id": p.payload["entry_id"], + "score": p.score, "entry_type": p.payload.get("entry_type")}) + + # Sort by score desc, truncate + results.sort(key=lambda r: r["score"], reverse=True) + top = results[:limit] + + # Stage 2: fetch full data from PostgreSQL by IDs + return await _hydrate_from_postgres(top, world_id) + +async def _hydrate_from_postgres(items: list[dict], world_id: UUID) -> list[dict]: + """Подтягивает полные данные entities.data и story_entries.content из PostgreSQL.""" + entity_ids = [UUID(i["id"]) for i in items if i["type"] == "entity"] + story_ids = [UUID(i["id"]) for i in items if i["type"] == "story_entry"] + + entities_map: dict[UUID, dict] = {} + stories_map: dict[UUID, dict] = {} + if entity_ids: + rows = await db.execute(select(Entity).where(Entity.id.in_(entity_ids))) + entities_map = {r.id: {"name": r.name, "entity_type": r.entity_type, "data": r.data} for r in rows.scalars()} + if story_ids: + rows = await db.execute(select(StoryEntry).where(StoryEntry.id.in_(story_ids))) + stories_map = {r.id: {"content": r.content, "entry_type": r.entry_type, "metadata": r.metadata} for r in rows.scalars()} + + out = [] + for i in items: + if i["type"] == "entity": + full = entities_map.get(UUID(i["id"])) + if full: + out.append({**i, "content": full}) + else: + full = stories_map.get(UUID(i["id"])) + if full: + out.append({**i, "content": full}) + return out +``` + +#### 11.1.5. Реализация rag_add + +```python +async def rag_add( + world_id: UUID, content: str, entry_type: str, metadata: dict | None = None, +) -> dict: + """Добавляет факт в story_entries + индексирует в Qdrant (синхронно).""" + entry = StoryEntry(world_id=world_id, content=content, entry_type=entry_type, + metadata=metadata or {}, embedding_status="pending") + db.add(entry) + await db.flush() # получаем entry.id + + try: + vec = (await get_embedder().embed([content[:4000]]))[0] + point_id = str(entry.id) + await get_qdrant_client().upsert( + collection_name="story_entries", + points=[PointStruct(id=point_id, vector=vec, + payload={"world_id": str(world_id), "entry_id": point_id, + "entry_type": entry_type, + "step_id": str(metadata.get("step_id")) if metadata else None, + "created_at": int(entry.created_at.timestamp())})] + ) + entry.qdrant_point_id = point_id + entry.embedding_status = "indexed" + except Exception as e: + logger.warning("rag_add_embed_failed", error=str(e), entry_id=str(entry.id)) + entry.embedding_status = "failed" + # Фоновой индексатор попробует снова + + await db.commit() + return {"id": str(entry.id), "status": entry.embedding_status} +``` + +#### 11.1.6. Удаление мира и Qdrant-очистка + +При `DELETE /api/worlds/{id}` (cascade delete в PostgreSQL) срабатывает хук `app/api/worlds.py::_cleanup_qdrant(world_id)`: + +```python +async def _cleanup_qdrant(world_id: UUID) -> None: + """Best-effort удаление точек мира из Qdrant. Ошибки логируем, но не падаем.""" + client = get_qdrant_client() + for collection in ("entities", "story_entries"): + try: + await client.delete( + collection_name=collection, + points_selector=FilterSelector( + filter=Filter(must=[FieldCondition(key="world_id", + match=MatchValue(value=str(world_id)))])) + ) + except Exception as e: + logger.error("qdrant_cleanup_failed", collection=collection, + world_id=str(world_id), error=str(e)) +``` + +Аналогично при soft-delete `entity.deleted_at = now()` — точка в Qdrant помечается `deleted: true` в payload (не удаляется физически, чтобы можно было восстановить; сборщик мусора раз в сутки удаляет `deleted: true` старше 7 дней). + + +### 11.2. Валидатор состояния + +`app/core/state_validator.py` — критический модуль. Любое изменение `environment` или `entity.data` проходит через него. + +**Сигнатуры:** + +```python +def validate_state(state: dict, schema: dict) -> tuple[bool, list[str]]: + """ + Возвращает (ok, errors). errors — список человекочитаемых строк. + Проверяет: + - Все обязательные поля присутствуют. + - Типы полей соответствуют объявленным. + - Значения в диапазонах (для integer с max). + - Структура object/array соответствует вложенной schema. + """ + +def apply_patch(state: dict, patch: dict) -> tuple[dict, list[str]]: + """ + Применяет JSON-patch к state и возвращает (new_state, errors). + patch: {field_path: new_value} или {field_path: {op: "inc", by: N}}. + Поддерживаемые op: 'set', 'inc', 'dec', 'append', 'remove'. + Возвращает new_state только если errors пуст. + """ + +def validate_world(world: World) -> tuple[bool, list[str]]: + """ + Полная валидация мира: schemas, environment_schema, environment, current_time. + Используется при создании/редактировании мира. + """ +``` + +**Что валидатор проверяет обязательно:** + +| Проверка | Уровень | Поведение при ошибке | +|---|---|---| +| Все обязательные поля `environment_schema` присутствуют в `environment` | syntax | `errors.append("Missing required field: player")` | +| `player` соответствует `schema character` (есть name, stats, stats.health — целое, etc.) | syntax | `errors.append("player.stats.health must be integer")` | +| `current_location` — непустая строка | syntax | `errors.append("current_location must be non-empty")` | +| `current_location` ссылается на существующую `Entity` типа `location` | semantic (soft) | warning, можно отключить в `settings` | +| `plot_rails` содержит `hooks` (list) и `current_goals` (list) | syntax | `errors.append("plot_rails.hooks must be list")` | +| `current_time` парсится как `day_D_hour_H[_min_M]` | syntax | `errors.append("Invalid current_time format")` | +| `hour < hours_in_day` из `time_schema` | semantic | `errors.append("hour 25 exceeds hours_in_day 24")` | +| Все типы полей соответствуют объявленным в `schema` (integer — int, boolean — bool, etc.) | syntax | `errors.append("Field X must be integer, got string")` | +| Для `integer` с `max`: значение ≤ max | semantic | `errors.append("health 150 exceeds max 100")` | +| Для `array` с `max`: длина ≤ max | semantic | `errors.append("inventory has 20 items, max 10")` | + +**Поток валидации при `env_update`:** + +```python +async def execute_env_update(patch: dict, ctx: ToolContext) -> ToolResult: + new_env, errors = apply_patch(ctx.world.environment, patch) + if errors: + return ToolResult(ok=False, error={"code": "validation_error", "message": "; ".join(errors)}) + ok, errors = validate_state(new_env, ctx.world.environment_schema) + if not ok: + return ToolResult(ok=False, error={"code": "validation_error", "message": "; ".join(errors)}) + # Дополнительная семантическая валидация + ok, errors = validate_world_state_semantic(new_env, ctx.world) + if not ok: + return ToolResult(ok=False, error={"code": "semantic_error", "message": "; ".join(errors)}) + + ctx.world.environment = new_env + await ctx.session.commit() + return ToolResult(ok=True, data={"applied_paths": list(patch.keys())}, message="Environment updated") +``` + +### 11.3. JSON-patch формат + +Поддерживаемые операции в `patch`: + +| Формат значения | Операция | Пример | +|---|---|---| +| `{"field": value}` | `set` (заменить) | `{"player.stats.health": 50}` | +| `{"field": {"op": "inc", "by": N}}` | `inc` (прибавить) | `{"player.stats.health": {"op": "inc", "by": -10}}` | +| `{"field": {"op": "dec", "by": N}}` | `dec` (вычесть) | `{"player.stats.mana": {"op": "dec", "by": 5}}` | +| `{"field": {"op": "append", "value": X}}` | `append` (добавить в массив) | `{"player.inventory": {"op": "append", "value": {"item_id": "sword", "qty": 1}}}` | +| `{"field": {"op": "remove", "index": N}}` | `remove` (удалить из массива по индексу) | `{"player.inventory": {"op": "remove", "index": 2}}` | + +`field_path` поддерживает точечную нотацию: `player.stats.health`, `plot_rails.current_goals[0]`, `schemas[1].properties[2].verbose`. + +### 11.4. Управление игровым временем + +`app/core/time_utils.py`: + +```python +def parse_time(s: str) -> dict: + """Парсит '[year_Y_]day_D_hour_H[_min_M]' в dict {year, day, hour, min}.""" + +def format_time(t: dict) -> str: + """Сериализует dict в строку.""" + +def advance_time(current: str, delta: str, time_schema: dict) -> str: + """ + Прибавляет delta к current, учитывая hours_in_day. + Пример: advance_time('day_1_hour_23', 'hours_2', {hours_in_day: 24}) -> 'day_2_hour_1'. + """ + +def compare_time(a: str, b: str) -> int: + """-1 если a < b, 0 если a == b, 1 если a > b. Для deferred_triggers.""" +``` + +### 11.5. Контекстная оптимизация (Context window management) + +LLM-контекст ограничен (8K–32K токенов для локальных моделей, до 128K для крупных cloud-моделей). Контекстный менеджер `app/engine/context.py` отвечает за то, чтобы каждый вызов LLM получал максимально информативный промпт, не превышающий бюджет. Это критически важно для долгих сессий: без сжатия контекст быстро переполняется, модель начинает «забывать» ранние события и галлюцинировать. + +#### 11.5.1. Бюджет токенов + +Каждый промпт делится на сегменты с фиксированными и динамическими бюджетами: + +| Сегмент | Типичный объём (токенов) | Управление | +|---|---|---| +| System prompt (роль + правила + схемы + environment) | 1500–3000 | Статичный шаблон + interpolated из `world.*` | +| Summary (если есть) | 300–800 | Генерируется в Phase 3, кешируется в `story_entries` | +| RAG-retrieved facts (если LLM вызывала `rag_query`) | 0–1500 | Динамически, по результатам tool call | +| Recent messages (guaranteed) | 1500–4000 | Последние N шагов (default 10) | +| Current action | 50–300 | Текущее действие игрока | +| Reserved for completion | 1024–4096 | `max_tokens` из `settings.llm.max_tokens` | + +**Формула бюджета:** + +``` +total = system + summary + rag + recent + action + reserved +total <= model_context_window - safety_margin (default 500 токенов) +``` + +Если бюджет превышен, контекстный менеджер поочерёдно применяет деградацию: + +1. Уменьшает `recent` — отбрасывает самые старые из guaranteed, но не ниже 4 последних шагей. +2. Уменьшает `rag` — отбрасывает самые низко-скоринговые факты. +3. Урезает `summary` до 200 токенов (берёт первое предложение + ключевые имена). +4. Если всё ещё превышено — форсирует генерацию нового summary для большего диапазона шагов и повторяет цикл. + +Если после всех шагов промпт всё ещё не помещается — возвращается ошибка `context_overflow`, итерация помечается `failed`, оператору показывается warning «пора увеличить context window модели или уменьшить guaranteed_messages». + +#### 11.5.2. Трёхуровневая стратегия контекста + +Контекст строится из трёх уровней, каждый со своей политикой устаревания: + +1. **Environment (всегда в контексте).** JSON-блок `player` + `current_location` + `plot_rails` + кастомные поля. Не требует tool call — LLM видит его сразу в system-промпте. Это «рабочая память» GM. Оптимальный размер — 1500–2500 токенов; если превышает, контекстный менеджер логирует warning и предлагает оператору упростить `environment_schema`. + +2. **Recent messages (guaranteed).** Последние N шагов (default 10, настраивается в `settings.context.guaranteed_messages`). Каждый шаг = `user: action` + `assistant: scene_text`. Если шаги длинные, контекстный менеджер обрезает `scene_text` до 500 токенов, сохраняя начало (200) и конец (300) — так сохраняются и вступление сцены, и финальное действие. + +3. **Summary (compressed history).** Когда `len(recent_steps) > settings.context.compression_threshold_messages` (default 20) или `token_count(recent) > compression_threshold_tokens` (default 6000), Phase 3 генерирует summary для отброшенных шагов. Summary хранится как `StoryEntry` с `entry_type='summary'` и `metadata={"step_range": [from_seq, to_seq]}`. При следующей итерации summary подставляется в контекст как `system`-message: `«Сводка прошлых событий (шаги 5-18): ...»`. Несколько summary могут сосуществовать, если сессия очень длинная — контекстный менеджер берёт самое свежее. + +#### 11.5.3. RAG-стратегия: когда подтягивать факты + +RAG-результаты **не добавляются в контекст автоматически** — это инструменты, LLM вызывает их сама когда считает нужным. Но контекстный менеджер даёт подсказку в system-промпте (на английском для лучшего качества — см. §10.1): + +> "If you need to recall details from the past (NPC names, world facts, past events), call `rag_query` with a descriptive query. Do NOT try to recall details from memory — you may hallucinate." + +**Принудительный RAG-call** (опционально, по настройке `context.auto_rag_on_entity_mention`, default `false`): если в `player_action` упоминается сущность по имени (простой regex-match по `entities.name`), контекстный менеджер делает `rag_query` автоматически и добавляет результаты как `system`-message: `«Relevant facts retrieved: ...»`. Это уменьшает количество tool calls и latency, но может подтянуть нерелевантный шум. По умолчанию **отключено** — оставляем решение за LLM. + +#### 11.5.4. Pre-filtering и изоляция миров + +Qdrant-фильтр `world_id == ` обязателен для каждого `rag_query`. Это гарантирует, что: + +- Сущности и факты мира A никогда не попадут в контекст игры в мире B. +- Удаление мира (`DELETE /api/worlds/{id}`) каскадно удаляет точки в Qdrant (см. §11.1.6). +- Бэкап и восстановление мира можно делать независимо от других миров (см. §13.6). + +#### 11.5.5. Гибридный поиск (опционально, future) + +В будущих спринтах (post-MVP) можно добавить гибридный поиск: BM25 (через PostgreSQL `tsvector` + GIN-индекс на `entities.name`, `story_entries.content`) + dense (Qdrant). RRF (Reciprocal Rank Fusion) объединяет ранжирования. Это улучшает поиск для коротких точных запросов (имена NPC, названия локаций). В текущем ТЗ **не обязательно**, но архитектура (двухстадийный retrieval из §11.1.4) это позволяет без переписывания. + +#### 11.5.6. Кеширование + +- **Embedding cache:** `app/core/cache.py::EmbeddingCache` — LRU на 1000 запросов, TTL 5 минут. Ключ = `sha256(text)[:16]`. Хранит только векторы запросов (`rag_query`); векторы документов живут в Qdrant. +- **Summary cache:** один summary на мир на step_range; инвалидируется при откате шагов (см. `DELETE /api/steps/{id}` rollback). +- **Prompt cache:** system-промпт кешируется после первой сборки для мира; инвалидируется при `world.schemas` / `world.environment_schema` изменениях (через dirty-flag в `worlds.updated_at`). + +#### 11.5.7. Оценка токенов + +```python +# app/core/tokens.py +def estimate_tokens(text: str, model: str) -> int: + """Приближённая оценка token count. + Для OpenAI-моделей — tiktoken; для остальных — len(text)/4.""" + try: + import tiktoken + enc = tiktoken.encoding_for_model(model) + return len(enc.encode(text)) + except Exception: + return max(1, len(text) // 4) +``` + +Используется в контекстном менеджере для всех сегментов. System prompt оценивается один раз при первой сборке и кешируется (его размер не меняется в пределах итерации, если не было `env_update`). + +#### 11.5.8. Настройки контекста (в `settings.context.*`) + +| Ключ | Default | Назначение | +|---|---|---| +| `context.guaranteed_messages` | `10` | Сколько последних шагей всегда в контексте | +| `context.compression_threshold_messages` | `20` | Порог сжатия по числу шагов | +| `context.compression_threshold_tokens` | `6000` | Альтернативный порог по токенам | +| `context.scene_text_truncate_tokens` | `500` | Урезка `scene_text` в recent messages | +| `context.auto_rag_on_entity_mention` | `false` | Авто-вызов `rag_query` при упоминании сущности | +| `context.safety_margin_tokens` | `500` | Резерв от края context window | + +### 11.6. Embeddings subsystem + +#### 11.6.1. Конфигурация (`settings.embeddings.*`) + +| Ключ | Тип | Default | Назначение | +|---|---|---|---| +| `embeddings.provider` | string | `"offline_hash"` | `offline_hash` \| `openai` | +| `embeddings.api_url` | string | `""` | URL OpenAI-compatible endpoint. **Если пусто и provider=`openai` — fallback на `llm.api_url`**. | +| `embeddings.api_key` | string | `""` | API-ключ. **Если пусто и provider=`openai` — fallback на `llm.api_key`**. | +| `embeddings.model` | string | `"text-embedding-3-small"` | Имя модели эмбеддингов | +| `embeddings.dimension` | integer | `1536` | Размерность. **Кнопка «Авто-проба»** в UI вызывает `POST /api/admin/test/embeddings/probe-dimension` и подставляет результат. | +| `embeddings.timeout_seconds` | integer | `30` | Timeout на вызов embeddings API | +| `embeddings.batch_size` | integer | `32` | Размер батча для embedding API | +| `embeddings.cache_ttl_seconds` | integer | `300` | TTL LRU-кеша для query embeddings | +| `embeddings.max_text_chars` | integer | `4000` | Урезка текста перед эмбеддингом (для `story_entries.content`) | + +#### 11.6.2. Fallback-правило для OpenAI-провайдера + +Если `embeddings.provider == "openai"` и **не заданы** `embeddings.api_url` или `embeddings.api_key`, конструктор `OpenAIEmbedder` берёт значения из `llm.api_url` и `llm.api_key`. Это позволяет единой конфигурацией LLM (`llm.api_url` + `llm.api_key`) закрыть и chat-completions, и embeddings — удобно для self-hosted инстансов (vLLM, ollama, lmdeploy) и для OpenAI-аккаунтов с обоими API. + +```python +# app/core/embeddings.py +def build_embedder() -> Embedder: + provider = settings.get("embeddings.provider") + dim = settings.get("embeddings.dimension") + if provider == "offline_hash": + return HashEmbedder(dimension=dim or 256) + if provider == "openai": + # Fallback: если embeddings.api_url/api_key пустые — берём llm.* + api_url = settings.get("embeddings.api_url") or settings.get("llm.api_url") + api_key = settings.get("embeddings.api_key") or settings.get("llm.api_key") + if not api_url or not api_key: + raise ValueError( + "OpenAI embedder requires api_url and api_key " + "(either embeddings.* or llm.* fallback)" + ) + model = settings.get("embeddings.model") or "text-embedding-3-small" + timeout = settings.get("embeddings.timeout_seconds") or 30 + return OpenAIEmbedder(api_url=api_url, api_key=api_key, model=model, + dimension=dim, timeout=timeout) + raise ValueError(f"Unknown embeddings provider: {provider}") +``` + +#### 11.6.3. Авто-проба размерности (`embeddings.dimension`) + +Размерность вектора **нельзя** угадать по имени модели — `text-embedding-3-small` поддерживает 512/1536/3072, локальные модели на vLLM могут иметь 768/1024/4096. Поэтому в админке есть кнопка **«Авто-проба»** рядом с полем `embeddings.dimension`: + +1. UI вызывает `POST /api/admin/test/embeddings/probe-dimension` с телом `{"text": "hello world"}`. +2. Backend дёргает `OpenAIEmbedder.embed(["hello world"])` с текущими настройками. +3. Возвращает `{"ok": true, "dimension": 1536, "model": "text-embedding-3-small", "elapsed_ms": 142}`. +4. UI показывает результат и предлагает кнопку **«Сохранить 1536 в `embeddings.dimension`»**. + +Если размерность поменялась — backend проверяет, что новая размерность совместима с существующими Qdrant-коллекциями. Если нет — предлагает пересоздать коллекции (с подтверждением админом) и запускает фоновую переиндексацию всех `entities` и `story_entries` (сбрасывает `embedding_status` в `pending`). + +#### 11.6.4. Фоновый индексатор + +`app/workers/embedding_indexer.py` — async-воркер, запускается через `asyncio.create_task` в startup-хуке (или через APScheduler в production). Каждые 30 секунд берёт до 50 записей с `embedding_status='pending'`, считает векторы и upsert-ит в Qdrant. + +```python +# app/workers/embedding_indexer.py +import asyncio, json +from app.core.embeddings import get_embedder +from app.core.rag import get_qdrant_client +from qdrant_client.http.models import PointStruct + +async def embedding_indexer_loop(): + while True: + try: + embedder = get_embedder() + qdrant = get_qdrant_client() + dim = embedder.dimension + + # Entities + pending = await db.execute( + select(Entity) + .where(Entity.embedding_status == "pending", Entity.deleted_at.is_(None)) + .limit(50) + ) + for entity in pending.scalars(): + text = f"{entity.name}\n{json.dumps(entity.data, ensure_ascii=False)[:2000]}" + try: + vec = (await embedder.embed([text]))[0] + point_id = str(entity.id) + await qdrant.upsert( + collection_name="entities", + points=[PointStruct(id=point_id, vector=vec, + payload={"world_id": str(entity.world_id), + "entity_id": point_id, + "entity_type": entity.entity_type, + "name": entity.name, "deleted": False})] + ) + entity.qdrant_point_id = point_id + entity.embedding_status = "indexed" + except Exception as e: + logger.warning("embedding_index_entity_failed", + entity_id=str(entity.id), error=str(e)) + entity.embedding_status = "failed" + + # StoryEntries — аналогично + # ... (см. полный код в app/workers/embedding_indexer.py) + + await db.commit() + except Exception as e: + logger.exception("embedding_indexer_error", error=str(e)) + await asyncio.sleep(30) +``` + +Записи с `embedding_status='failed'` (после 3 ретраев) попадают в лог и доступны для ручного retry через `POST /api/admin/embeddings/retry-failed`. + +#### 11.6.5. Текст для эмбеддинга + +Чтобы вектор был информативным, текст формируется не только из основного поля: + +| Сущность | Формула | Пример | +|---|---|---| +| `Entity` (character) | `name + "\n" + role + "\n" + key_stats + "\n" + description` | `«Sir Galahad\nknight\nHP:80/100, STR:16\nA noble warrior sworn to...»` | +| `Entity` (item) | `name + "\n" + item_type + "\n" + properties` | `«Iron Sword\nweapon\ndamage:1d8+1, weight:3kg»` | +| `Entity` (location) | `name + "\n" + description` | `«Whispering Forest\nA dense wood where...»` | +| `StoryEntry` | `content` (с урезкой до `embeddings.max_text_chars`) | `«Игрок убил дракона в день 3 час 14.»` | + +Формула фиксирована в `app/core/embeddings.py::build_text_for_entity(entity)` и `::build_text_for_story_entry(entry)`. Менять формулу = пересоздать коллекцию и переиндексировать. + +#### 11.6.6. Тестовые кнопки (см. §6.5 и §12.4) + +В админке на странице настроек есть три тестовые кнопки + кнопка авто-пробы: + +| Кнопка | Endpoint | Что делает | Что показывает UI | +|---|---|---|---| +| **«Тест LLM»** | `POST /api/admin/test/llm` | Отправляет `"Reply with: OK"` в chat/completions | Ответ модели + latency + token counts | +| **«Тест LLM вызов инструментов»** | `POST /api/admin/test/llm-tools` | Отправляет промпт `"What is 2+2? Use the calc tool."` + 1 инструмент `calc` | Был ли `tool_calls` в ответе, latency, имя вызванного инструмента | +| **«Тест эмбеддинг»** | `POST /api/admin/test/embeddings` | Отправляет `"hello world"` в embeddings API | dimension + первые 5 значений вектора + latency | +| **«Авто-проба размерности»** | `POST /api/admin/test/embeddings/probe-dimension` | То же что «Тест эмбеддинг», но возвращает только dimension | dimension + кнопка «Сохранить в `embeddings.dimension`» | + +Все четыре кнопки доступны без сохранения настроек — берут текущие значения из формы (query params `?api_url=&api_key=&model=`), что позволяет тестировать перед сохранением. См. §6.5 для контрактов endpoints. + + +--- + +## 12. Фронтенд-архитектура + +### 12.1. Структура директорий + +``` +frontend/ +├── src/ +│ ├── main.tsx +│ ├── App.tsx +│ ├── routes/ +│ │ ├── index.tsx # Маршруты (react-router v6) +│ │ ├── ProtectedRoute.tsx +│ │ └── AdminRoute.tsx +│ ├── pages/ +│ │ ├── LoginPage.tsx +│ │ ├── RegisterPage.tsx +│ │ ├── RegisterAdminPage.tsx +│ │ ├── WorldsListPage.tsx +│ │ ├── WorldCreatePage.tsx +│ │ ├── WorldEditPage.tsx +│ │ ├── WorldPlayPage.tsx +│ │ └── admin/ +│ │ ├── AdminSettingsPage.tsx +│ │ ├── AdminLogsPage.tsx +│ │ └── AdminUsersPage.tsx +│ ├── components/ +│ │ ├── ui/ # Базовые компоненты +│ │ │ ├── Button.tsx +│ │ │ ├── Card.tsx +│ │ │ ├── Input.tsx +│ │ │ ├── Modal.tsx +│ │ │ ├── Navbar.tsx +│ │ │ ├── Spinner.tsx +│ │ │ └── cn.ts # classnames утилита +│ │ ├── chat/ +│ │ │ ├── ChatWindow.tsx +│ │ │ ├── ChatMessage.tsx +│ │ │ ├── ToolCallBubble.tsx +│ │ │ ├── ActionSelector.tsx +│ │ │ └── ClarificationModal.tsx +│ │ ├── world/ +│ │ │ ├── WorldCard.tsx +│ │ │ ├── EnvironmentPanel.tsx +│ │ │ ├── EntityList.tsx +│ │ │ ├── PlotRailsPanel.tsx +│ │ │ └── JsonEditor.tsx +│ │ └── admin/ +│ │ ├── SettingsForm.tsx +│ │ ├── LogsTable.tsx +│ │ ├── LogDetailModal.tsx +│ │ ├── ConnectionTests.tsx # блок с 3 тестовыми кнопками + probe-dimension +│ │ ├── TestLlmButton.tsx # «Тест LLM» +│ │ ├── TestLlmToolsButton.tsx # «Тест LLM вызов инструментов» +│ │ ├── TestEmbeddingsButton.tsx # «Тест эмбеддинг» +│ │ ├── ProbeDimensionButton.tsx # «Авто-проба размерности» +│ │ ├── IconUploader.tsx # загрузка favicon/logo через UI +│ │ └── TestResultCard.tsx # карточка с результатом теста +│ ├── stores/ # zustand +│ │ ├── authStore.ts +│ │ ├── uiStore.ts +│ │ ├── worldsStore.ts +│ │ └── sessionStore.ts +│ ├── api/ # HTTP-клиент +│ │ ├── client.ts # fetch wrapper with JWT +│ │ ├── auth.ts +│ │ ├── worlds.ts +│ │ ├── sessions.ts +│ │ ├── admin.ts +│ │ └── sse.ts # EventSource wrapper +│ ├── i18n/ +│ │ ├── config.ts +│ │ ├── en.json +│ │ └── ru.json +│ ├── lib/ +│ │ ├── utils.ts +│ │ └── constants.ts +│ └── types/ +│ ├── api.ts # типы из OpenAPI +│ ├── world.ts +│ └── sse.ts +├── public/ +├── index.html +├── vite.config.ts +├── tailwind.config.js +├── tsconfig.json +└── package.json +``` + +### 12.2. Zustand stores + +#### `authStore` +```typescript +interface AuthState { + user: User | null; + accessToken: string | null; + refreshToken: string | null; + isAuthenticated: boolean; + login: (creds: LoginRequest) => Promise; + logout: () => void; + refresh: () => Promise; + fetchMe: () => Promise; +} +``` + +Хранит токены в `localStorage`. При истечении access_token — автоматически вызывает `refresh()`. + +#### `worldsStore` +```typescript +interface WorldsState { + worlds: WorldSummary[]; + currentWorld: World | null; + isLoading: boolean; + error: string | null; + fetchWorlds: () => Promise; + fetchWorld: (id: string) => Promise; + createWorld: (req: CreateWorldRequest) => Promise; + deleteWorld: (id: string) => Promise; +} +``` + +#### `sessionStore` +```typescript +interface SessionState { + steps: Step[]; + environment: Environment | null; + nextActions: string[]; + isIterating: boolean; + currentPhase: 1 | 2 | 3 | null; + currentToolCall: ToolCall | null; + sceneStreaming: boolean; + partialScene: string; + sseConnection: EventSource | null; + + connectStream: (url: string) => void; + disconnectStream: () => void; + iterate: (action: string, source: 'custom' | 'suggested') => Promise; + retry: () => Promise; + rollback: () => Promise; + answerClarification: (text: string) => Promise; +} +``` + +#### `uiStore` +```typescript +interface TestResult { + kind: 'llm' | 'llm_tools' | 'embeddings' | 'probe_dimension'; + ok: boolean; + warning?: boolean; + data: Record; // response, dimension, latency_ms, ... + error?: { code: string; message: string }; + testedAt: string; // ISO timestamp +} + +interface UIState { + language: 'en' | 'ru'; + theme: 'light' | 'dark'; + sidebarOpen: boolean; + // Результаты тестовых кнопок (§12.4 п.10-12) + testResults: TestResult[]; // последние 10 результатов + addTestResult: (r: TestResult) => void; + clearTestResults: () => void; + // Иконки, загруженные через UI (§12.4 п.11) + faviconUrl: string | null; + logoUrl: string | null; + ogImageUrl: string | null; + setIcon: (kind: 'favicon' | 'logo' | 'og_image', url: string) => void; + // Базовые setter-ы + setLanguage: (lang: 'en' | 'ru') => void; + toggleTheme: () => void; +} +``` + +### 12.3. SSE-обработчик + +`src/api/sse.ts`: + +```typescript +export class SSEClient { + private eventSource: EventSource | null = null; + private lastEventId: string | null = null; + + connect(url: string, handlers: SSEHandlers): void { + this.eventSource = new EventSource(url, { withCredentials: true }); + this.eventSource.onmessage = (e) => this.handle('message', e); + this.eventSource.addEventListener('phase_start', (e) => handlers.onPhaseStart?.(JSON.parse(e.data))); + this.eventSource.addEventListener('tool_call', (e) => handlers.onToolCall?.(JSON.parse(e.data))); + this.eventSource.addEventListener('scene_chunk', (e) => handlers.onSceneChunk?.(JSON.parse(e.data))); + this.eventSource.addEventListener('scene_complete', (e) => handlers.onSceneComplete?.(JSON.parse(e.data))); + this.eventSource.addEventListener('suggested_actions', (e) => handlers.onSuggestedActions?.(JSON.parse(e.data))); + this.eventSource.addEventListener('error', (e) => handlers.onError?.(JSON.parse(e.data))); + this.eventSource.addEventListener('done', (e) => { + handlers.onDone?.(JSON.parse(e.data)); + this.disconnect(); + }); + this.eventSource.addEventListener('ping', (e) => { + this.lastEventId = e.lastEventId; + }); + } + + disconnect(): void { + this.eventSource?.close(); + this.eventSource = null; + } +} +``` + +### 12.4. UX-требования + +1. **Прогресс LLM-шагов.** Во время orchestrator показывает прогресс-бар с тремя фазами. Текущая фаза подсвечена, completed — зелёная, pending — серая. + +2. **Пузырьки tool calls.** Каждый вызванный инструмент отображается как сворачиваемый пузырь в чате: `{tool: 'env_update', summary: 'player.stats.health -10', is_success: true}`. По клику разворачивается полный `arguments` и `result`. + +3. **Streaming текста.** Phase 2 `scene_text` стримится посимвольно (или по словам для оптимизации). Текст появляется "печатной машинкой". + +4. **Кнопка "Повторить генерацию".** Показывается если: + - SSE закрылся с `error` событием. + - Таймаут 60 секунд без событий. + - Пользователь вручную нажал "Отменить" → step помечается `failed`, доступен retry. + +5. **Clarification modal.** Когда world_editor вызывает `ask_user`, появляется модальное окно с вопросом и опциональными вариантами. Поле ответа — textarea, кнопка "Отправить". + +6. **Rollback.** Кнопка "Откатить последний ход" в меню шага. Подтверждение через modal. + +7. **i18n.** Все UI-строки — через `react-i18next` `t('key')`. Язык переключается в шапке, сохраняется в `localStorage`. При смене языка — пере-рендер без перезагрузки страницы. + +8. **Тёмная тема.** Tailwind `dark:` классы. Сохраняется в `uiStore.theme`. + +9. **Responsive.** Mobile-first. На экранах <768px чат занимает всю ширину, environment panel сворачивается в drawer. + +10. **Страница настроек админки — диагностические кнопки.** На `/admin` рядом с каждой группой настроек (LLM, Embeddings) выведены тестовые кнопки. Кнопки используют **текущие значения из формы** (query params), а не сохранённые в `settings` — это позволяет проверить конфигурацию до сохранения. + - **«Тест LLM»** (`TestLlmButton.tsx`) — рядом с группой `llm.*`. Вызывает `POST /api/admin/test/llm?api_url=&api_key=&model=`. Показывает `TestResultCard` с ответом модели, latency, token counts. При ошибке — красная карточка с сообщением. + - **«Тест LLM вызов инструментов»** (`TestLlmToolsButton.tsx`) — под кнопкой «Тест LLM». Вызывает `POST /api/admin/test/llm-tools?...`. Показывает, вернула ли модель `tool_calls` и какой именно инструмент вызван. + - **«Тест эмбеддинг»** (`TestEmbeddingsButton.tsx`) — рядом с группой `embeddings.*`. Вызывает `POST /api/admin/test/embeddings?...`. Показывает dimension, первые 5 значений вектора, latency. + - **«Авто-проба размерности»** (`ProbeDimensionButton.tsx`) — рядом с полем `embeddings.dimension`. Вызывает `POST /api/admin/test/embeddings/probe-dimension?...`. Возвращает dimension. Под кнопкой появляется inline-подсказка: `«Обнаружена размерность: 1536. [Сохранить в embeddings.dimension]»`. Клик по «Сохранить» подставляет значение в поле формы (не сохраняет в БД — для сохранения используется общая кнопка «Сохранить настройки» внизу страницы). + - Все четыре кнопки показывают `Spinner` во время выполнения (до 30 секунд timeout). При timeout — красная карточка с кнопкой «Повторить». + - Результаты тестов не пропадают при переключении вкладок — хранятся в `uiStore.testResults` (последние 10 результатов). + +11. **Загрузка иконки через UI.** Компонент `IconUploader.tsx` на странице настроек позволяет загрузить: + - **favicon** (PNG 32×32 / 64×64, до 100KB) — обновляет `settings.ui.favicon_url`. + - **logo** (PNG/SVG, до 1MB) — обновляет `settings.ui.logo_url`. Логотип показывается в шапке приложения и на странице входа. + - **og_image** (PNG 1200×630, до 1MB) — обновляет `settings.ui.og_image_url` для Open Graph. + - Drag-and-drop зона + кнопка «Выбрать файл». Предпросмотр текущей иконки рядом с зоной загрузки. + - Вызывает `POST /api/admin/upload-icon` (multipart/form-data). После успешной загрузки показывает toast «Иконка обновлена» и обновляет предпросмотр. + - Поддерживаемые форматы: PNG, SVG, ICO (для favicon). Запрещены: всё остальное. MIME-type проверяется на бэке. + +12. **Превью результата тестов.** `TestResultCard.tsx` показывает: + - Зелёная карточка при `ok=true`: основная информация (response / dimension / latency). + - Красная карточка при `ok=false`: код ошибки, сообщение, кнопка «Подробнее» (разворачивает stack trace из `error.message`). + - Жёлтая карточка при warning (например, `has_tool_calls=false` в тесте LLM-tools). + - Время выполнения теста сохраняется и отображается мелким шрифтом: `«Обновлено: 2026-06-20 14:23»`. + +### 12.5. Маршруты + +| Path | Component | Auth | Описание | +|---|---|---|---| +| `/login` | LoginPage | — | Вход | +| `/register` | RegisterPage | — | Регистрация (если есть админ) | +| `/register/admin` | RegisterAdminPage | token | Создание админа | +| `/worlds` | WorldsListPage | user | Список миров | +| `/worlds/new` | WorldCreatePage | user | Создание мира | +| `/worlds/:id/edit` | WorldEditPage | owner | Редактирование мира | +| `/worlds/:id/play` | WorldPlayPage | owner | Игра | +| `/admin` | AdminSettingsPage | admin | Настройки | +| `/admin/logs` | AdminLogsPage | admin | Логи LLM | +| `/admin/users` | AdminUsersPage | admin | Пользователи | + +### 12.6. Локализация + +Файлы `i18n/en.json` и `i18n/ru.json` — плоские ключи: + +```json +{ + "common.save": "Сохранить", + "common.cancel": "Отмена", + "common.retry": "Повторить", + "auth.login.title": "Вход в систему", + "auth.login.email_or_username": "Email или имя пользователя", + "worlds.list.title": "Мои миры", + "worlds.list.empty": "У вас пока нет миров. Создайте первый!", + "play.phase.1": "Планирование", + "play.phase.2": "Написание сцены", + "play.phase.3": "Сохранение", + "play.tool_call.env_update": "Изменение окружения", + "play.tool_call.entity_create": "Создание сущности", + "play.error.timeout": "Время ожидания истекло. Попробуйте ещё раз." +} +``` + +--- + +## 13. DevOps / Deployment + +### 13.1. Docker Compose + +`docker-compose.yml`: + +```yaml +version: '3.9' + +services: + db: + image: postgres:15-alpine + container_name: airpg_db + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - db_data:/var/lib/postgresql/data + - ${DATA_DIR}/backups:/backups + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + + qdrant: + image: qdrant/qdrant:v1.8.4 + container_name: airpg_qdrant + environment: + QDRANT__SERVICE__GRPC_PORT: "6334" + QDRANT__SERVICE__HTTP_PORT: "6333" + QDRANT__LOG_LEVEL: ${QDRANT_LOG_LEVEL:-INFO} + # Включить авторизацию в проде: см. Qdrant docs, API key scopes + QDRANT__SERVICE__API_KEY: ${QDRANT_API_KEY:-} + volumes: + - qdrant_data:/qdrant/storage + - ${DATA_DIR}/qdrant_snapshots:/qdrant/snapshots + ports: + - "6333:6333" # HTTP (REST + Web UI) + - "6334:6334" # gRPC (используется qdrant-client) + healthcheck: + test: ["CMD-SHELL", "bash -c ':> /dev/tcp/127.0.0.1/6333' || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: airpg_backend + env_file: .env + depends_on: + db: + condition: service_healthy + qdrant: + condition: service_healthy + ports: + - "8000:8000" + volumes: + - ${DATA_DIR}:/app/data + - ./backend/app:/app/app # для dev hot-reload + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: airpg_frontend + depends_on: + - backend + ports: + - "80:80" + volumes: + - ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro + +volumes: + db_data: + qdrant_data: +``` + +### 13.2. Backend Dockerfile + +```dockerfile +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + build-essential \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +### 13.3. Frontend Dockerfile + nginx + +```dockerfile +# build stage +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# serve stage +FROM nginx:1.24-alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] +``` + +`nginx.conf`: +```nginx +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE: отключаем буферизацию + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 300s; + } + + location / { + try_files $uri $uri/ /index.html; + } +} +``` + +### 13.4. .env — полный список переменных + +```bash +# === Database === +POSTGRES_DB=airpg +POSTGRES_USER=airpg +POSTGRES_PASSWORD=changeme_strong_password +POSTGRES_HOST=db +POSTGRES_PORT=5432 +DATABASE_URL=postgresql+asyncpg://airpg:changeme_strong_password@db:5432/airpg + +# === JWT === +JWT_SECRET=change_this_to_random_64_char_string +JWT_ALGORITHM=HS256 +JWT_ACCESS_EXPIRE_MINUTES=1440 +JWT_REFRESH_EXPIRE_DAYS=7 + +# === Admin Setup === +ADMIN_SETUP_TOKEN= # empty = generate random on startup + +# === LLM === +LLM_API_URL=http://localhost:11434/v1 +LLM_API_KEY= +LLM_MODEL=qwen2.5-7b-instruct + +# === Embeddings === +# Если EMBEDDINGS_PROVIDER=openai и EMBEDDINGS_API_URL/EMBEDDINGS_API_KEY пусты — +# берутся LLM_API_URL/LLM_API_KEY (fallback, см. §11.6.2). +EMBEDDINGS_PROVIDER=offline_hash # or "openai" +EMBEDDINGS_API_URL= # пусто = fallback на LLM_API_URL +EMBEDDINGS_API_KEY= # пусто = fallback на LLM_API_KEY +EMBEDDINGS_MODEL=text-embedding-3-small +EMBEDDINGS_DIMENSION=1536 # кнопка «Авто-проба» в UI подставит правильное +EMBEDDINGS_TIMEOUT_SECONDS=30 +EMBEDDINGS_BATCH_SIZE=32 +EMBEDDINGS_CACHE_TTL_SECONDS=300 +EMBEDDINGS_MAX_TEXT_CHARS=4000 + +# === Qdrant === +QDRANT_URL=http://qdrant:6333 +QDRANT_API_KEY= # пусто = без авторизации (dev); в prod обязателен +QDRANT_COLLECTION_PREFIX= # для multi-tenant деплоя +QDRANT_LOG_LEVEL=INFO + +# === Context Manager === +CONTEXT_GUARANTEED_MESSAGES=10 +CONTEXT_COMPRESSION_THRESHOLD_MESSAGES=20 +CONTEXT_COMPRESSION_THRESHOLD_TOKENS=6000 +CONTEXT_SCENE_TEXT_TRUNCATE_TOKENS=500 +CONTEXT_AUTO_RAG_ON_ENTITY_MENTION=false +CONTEXT_SAFETY_MARGIN_TOKENS=500 + +# === Data === +DATA_DIR=/app/data + +# === App === +APP_ENV=development # development | staging | production +APP_LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR +APP_CORS_ORIGINS=http://localhost:5173,http://localhost + +# === Rate Limiting === +RATE_LIMIT_PER_MINUTE=60 +RATE_LIMIT_LLM_PER_MINUTE=10 +``` + +### 13.5. CI/CD (GitHub Actions) + +`.github/workflows/ci.yml`: + +```yaml +name: CI + +on: [push, pull_request] + +jobs: + backend-test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_PASSWORD: test + POSTGRES_DB: airpg_test + POSTGRES_USER: test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + qdrant: + image: qdrant/qdrant:v1.8.4 + ports: + - 6333:6333 + - 6334:6334 + options: >- + --health-cmd ":> /dev/tcp/127.0.0.1/6333" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/airpg_test + QDRANT_URL: http://localhost:6333 + EMBEDDINGS_PROVIDER: offline_hash + LLM_API_URL: http://localhost:11434/v1 + LLM_MODEL: mock + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt + - run: cd backend && pytest --cov=app --cov-report=xml --cov-fail-under=80 + - uses: codecov/codecov-action@v4 + + frontend-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: cd frontend && npm ci + - run: cd frontend && npm run lint + - run: cd frontend && npm run typecheck + - run: cd frontend && npm run test -- --coverage + - run: cd frontend && npx playwright test + + docker-build: + needs: [backend-test, frontend-test] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + - run: docker compose build + - run: docker compose push # если настроен registry +``` + +### 13.6. Бэкапы БД + Qdrant + +Cron-скрипт `scripts/backup_all.sh` делает резервную копию PostgreSQL **и** Qdrant. Qdrant-снапшот создаётся через REST API `POST /collections/{collection_name}/snapshots`, файлы сохраняются в `${DATA_DIR}/qdrant_snapshots/` (проброшен volume в docker-compose). + +```bash +#!/bin/bash +set -euo pipefail + +BACKUP_DIR="${DATA_DIR}/backups" +QDRANT_SNAP_DIR="${DATA_DIR}/qdrant_snapshots" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) + +mkdir -p "${BACKUP_DIR}" "${QDRANT_SNAP_DIR}" + +# 1. PostgreSQL dump +PG_FILE="${BACKUP_DIR}/airpg_${TIMESTAMP}.sql.gz" +docker exec airpg_db pg_dump -U "${POSTGRES_USER}" "${POSTGRES_DB}" | gzip > "${PG_FILE}" + +# 2. Qdrant snapshots для каждой коллекции +for COLLECTION in entities story_entries; do + # Запрашиваем создание снапшота на стороне Qdrant + SNAP_INFO=$(curl -s -X POST "${QDRANT_URL}/collections/${COLLECTION}/snapshots") + SNAP_NAME=$(echo "${SNAP_INFO}" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['name'])") + # Скачиваем снапшот в локальную папку + curl -s -o "${QDRANT_SNAP_DIR}/${COLLECTION}_${TIMESTAMP}.tar" \ + "${QDRANT_URL}/collections/${COLLECTION}/snapshots/${SNAP_NAME}" + # Удаляем снапшот с Qdrant-инстанса (файл уже у нас) + curl -s -X DELETE "${QDRANT_URL}/collections/${COLLECTION}/snapshots/${SNAP_NAME}" > /dev/null +done + +# Храним последние 30 дней +find "${BACKUP_DIR}" -name "airpg_*.sql.gz" -mtime +30 -delete +find "${QDRANT_SNAP_DIR}" -name "*.tar" -mtime +30 -delete + +echo "Backup saved: ${PG_FILE}, Qdrant snapshots: ${QDRANT_SNAP_DIR}/${COLLECTION}_${TIMESTAMP}.tar (x2)" +``` + +**Восстановление** (disaster recovery): + +```bash +# 1. PostgreSQL +zcat ${DATA_DIR}/backups/airpg_YYYYMMDD.sql.gz | docker exec -i airpg_db psql -U ${POSTGRES_USER} ${POSTGRES_DB} + +# 2. Qdrant (для каждой коллекции) +curl -X PUT "${QDRANT_URL}/collections/entities/snapshots/upload" \ + -F 'file=@${DATA_DIR}/qdrant_snapshots/entities_YYYYMMDD.tar' +``` + +Cron: `0 3 * * * /app/scripts/backup_all.sh` (ежедневно в 3:00). + +### 13.7. Логирование + +Структурированные логи в JSON через `structlog`: + +```python +import structlog +logger = structlog.get_logger() + +logger.info("llm_call", + stage="orchestrator_phase1", + world_id=str(world_id), + step_id=str(step_id), + model="qwen2.5-7b", + latency_ms=1234, + tokens=567, +) +``` + +В продакшене логи идут в stdout, docker собирает их через logging driver в centralized system (Loki/ELK). + +### 13.8. Health-checks + +- `GET /api/health` — liveness + readiness: `{status, db, qdrant, llm, embeddings, version}`. Проверки: + - `db` — `SELECT 1` через SQLAlchemy. + - `qdrant` — `GET /collections` через qdrant-client (проверяем что коллекции `entities` и `story_entries` существуют). + - `llm` — `false` если `settings.llm.api_url` пустой; `true` если последний вызов LLM был успешным (по `llm_call_logs` за последние 5 минут). + - `embeddings` — `false` если `provider=offline_hash`; `true` если последний embedding call был успешным. +- Docker healthcheck для backend: `curl -f http://localhost:8000/api/health || exit 1`. +- Docker healthcheck для db: `pg_isready`. +- Docker healthcheck для qdrant: TCP-проверка на порт 6333. + +### 13.9. Monitoring (опционально, для прод) + +- Prometheus metrics endpoint: `GET /metrics` (counter запросов, latency гистограммы, LLM call counters). +- Grafana dashboard: QPS, p50/p95 latency, LLM error rate, active SSE connections, DB pool size. + +--- + +## 14. TDD-методология и тестовая инфраструктура + +### 14.1. Red-Green-Refactor цикл + +**Каждое изменение в коде начинается с теста.** Это обязательное правило для ИИ-агента-разработчика. Цикл: + +1. **Red:** Напиши тест, который описывает желаемое поведение. Запусти — он падает (потому что реализации нет или она неверна). +2. **Green:** Напиши минимальную реализацию, чтобы тест прошёл. Не больше, не меньше. +3. **Refactor:** Улучши код, сохраняя тесты зелёными. Удали дублирование, вынеси общее, переименуй. + +**Правило "один тест — одно поведение":** каждый тест проверяет ровно одну вещь. Если тест упал, должно быть ясно, что именно сломалось. + +**Правило "тесты не тестируют реализацию, они тестируют контракт":** тесты не должны зависеть от внутренней структуры кода. Если рефакторинг поменял внутренности, но поведение сохранилось — тесты остаются зелёными. + +### 14.2. Структура тестового проекта + +``` +backend/ +├── tests/ +│ ├── conftest.py # общие фикстуры +│ ├── unit/ +│ │ ├── core/ +│ │ │ ├── test_state_validator.py +│ │ │ ├── test_llm_client.py +│ │ │ ├── test_rag.py +│ │ │ ├── test_security.py +│ │ │ └── test_time_utils.py +│ │ ├── engine/ +│ │ │ ├── test_game_master.py +│ │ │ ├── test_world_builder.py +│ │ │ ├── test_world_editor.py +│ │ │ ├── test_context.py +│ │ │ └── tools/ +│ │ │ ├── test_registry.py +│ │ │ ├── test_env_update.py +│ │ │ ├── test_entity_tools.py +│ │ │ └── test_calc.py +│ │ └── prompts/ +│ │ └── test_registry.py +│ ├── integration/ +│ │ ├── api/ +│ │ │ ├── test_auth.py +│ │ │ ├── test_worlds.py +│ │ │ ├── test_sessions.py +│ │ │ └── test_admin.py +│ │ ├── flows/ +│ │ │ ├── test_world_builder_flow.py +│ │ │ ├── test_world_editor_flow.py +│ │ │ ├── test_orchestrator_flow.py +│ │ │ └── test_intro_scene_flow.py +│ │ └── sse/ +│ │ └── test_sse_events.py +│ ├── e2e/ +│ │ ├── test_full_session.py +│ │ └── test_admin_setup.py +│ └── fixtures/ +│ ├── llm_replay/ # JSON-replay фикстуры LLM-ответов +│ │ ├── world_builder_basic.json +│ │ ├── orchestrator_combat.json +│ │ └── ... +│ ├── db_seeds/ +│ │ ├── fantasy_world.json +│ │ └── sci-fi_world.json +│ └── schemas/ +│ ├── valid_world.json +│ └── invalid_worlds.json +``` + +### 14.3. Фикстуры и моки + +#### `conftest.py` — ключевые фикстуры + +```python +import pytest +import pytest_asyncio +from httpx import AsyncClient +from testcontainers.postgres import PostgresContainer +from testcontainers.qdrant import QdrantContainer # см. https://github.com/testcontainers/testcontainers-python + +@pytest_asyncio.fixture +async def db_session(): + """Изолированная БД для каждого теста через testcontainers.""" + with PostgresContainer("postgres:15-alpine") as pg: + engine = create_async_engine(pg.get_connection_url()) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + async with AsyncSession(engine) as session: + yield session + +@pytest_asyncio.fixture +async def qdrant_client(): + """Изолированный Qdrant для каждого теста через testcontainers.""" + with QdrantContainer("qdrant/qdrant:v1.8.4") as qd: + client = AsyncQdrantClient(url=f"http://localhost:{qd.get_exposed_port(6333)}") + # Создаём тестовые коллекции с малой размерностью (для HashEmbedder) + await init_qdrant_collections(dimension=256) + yield client + +@pytest_asyncio.fixture +async def mock_llm_client(): + """LLM-клиент, который возвращает предзаписанные ответы.""" + return MockLLMClient(replay_dir="tests/fixtures/llm_replay/") + +@pytest_asyncio.fixture +async def api_client(db_session, mock_llm_client): + """FastAPI TestClient с подменённой БД и LLM.""" + app = create_app(db_session, mock_llm_client) + async with AsyncClient(app=app, base_url="http://test") as client: + yield client + +@pytest.fixture +def sample_world(): + """Готовый мир для тестов.""" + return load_fixture("db_seeds/fantasy_world.json") + +@pytest.fixture +def admin_user(db_session): + user = User(email="admin@test", username="admin", is_admin=True, ...) + db_session.add(user) + await db_session.commit() + return user + +@pytest.fixture +def auth_token(admin_user): + return create_access_token({"sub": str(admin_user.id)}) +``` + +#### `MockLLMClient` — replay-based мок + +```python +class MockLLMClient: + """ + Возвращает предзаписанные ответы вместо реальных LLM-вызовов. + Replay-файл: {stage: [sequence_of_responses]}. + Каждый вызов LLM возвращает следующий ответ из sequence. + """ + + def __init__(self, replay_dir: str): + self.replay_dir = replay_dir + self._call_counts: dict[str, int] = {} + + async def complete(self, stage: str, messages: list, tools: list = None, **kwargs) -> dict: + replay_file = Path(self.replay_dir) / f"{stage}.json" + replay_data = json.loads(replay_file.read_text()) + idx = self._call_counts.get(stage, 0) + if idx >= len(replay_data): + raise RuntimeError(f"Replay exhausted for stage {stage}") + response = replay_data[idx] + self._call_counts[stage] = idx + 1 + return response + + async def stream(self, stage: str, messages: list, **kwargs): + replay_file = Path(self.replay_dir) / f"{stage}.json" + replay_data = json.loads(replay_file.read_text()) + for chunk in replay_data: + yield chunk +``` + +**Запись replay-фикстур:** в dev-режиме флаг `LLM_RECORD_REPLAY=true` заставляет реальный `LlmClient` писать каждый ответ в JSON-файл. Эти файлы коммитятся в репозиторий и используются в тестах. + +### 14.4. Примеры TDD-циклов + +#### Пример 1: `state_validator.apply_patch` + +**Red — пишем failing тест:** + +```python +# tests/unit/core/test_state_validator.py +import pytest +from app.core.state_validator import apply_patch + +class TestApplyPatch: + def test_set_operation_replaces_value(self): + state = {"player": {"name": "Эрик", "stats": {"health": 100}}} + patch = {"player.stats.health": 50} + new_state, errors = apply_patch(state, patch) + assert errors == [] + assert new_state["player"]["stats"]["health"] == 50 + + def test_inc_operation_adds_to_value(self): + state = {"player": {"stats": {"health": 100}}} + patch = {"player.stats.health": {"op": "inc", "by": -10}} + new_state, errors = apply_patch(state, patch) + assert errors == [] + assert new_state["player"]["stats"]["health"] == 90 + + def test_inc_on_non_integer_returns_error(self): + state = {"player": {"name": "Эрик"}} + patch = {"player.name": {"op": "inc", "by": 1}} + new_state, errors = apply_patch(state, patch) + assert errors == ["Cannot inc field 'player.name': not an integer"] + assert new_state is None + + def test_unknown_operation_returns_error(self): + state = {"player": {"health": 100}} + patch = {"player.health": {"op": "multiply", "by": 2}} + _, errors = apply_patch(state, patch) + assert "Unknown operation: multiply" in errors[0] + + def test_nested_path_with_array_index(self): + state = {"inventory": [{"item_id": "sword", "qty": 1}]} + patch = {"inventory[0].qty": 5} + new_state, errors = apply_patch(state, patch) + assert errors == [] + assert new_state["inventory"][0]["qty"] == 5 +``` + +**Green — минимальная реализация:** + +```python +# app/core/state_validator.py +def apply_patch(state: dict, patch: dict) -> tuple[dict | None, list[str]]: + new_state = deepcopy(state) + errors = [] + for path, op_spec in patch.items(): + try: + if isinstance(op_spec, dict) and "op" in op_spec: + op = op_spec["op"] + if op == "inc": + _apply_inc(new_state, path, op_spec["by"]) + elif op == "dec": + _apply_inc(new_state, path, -op_spec["by"]) + elif op == "set": + _apply_set(new_state, path, op_spec.get("value")) + elif op == "append": + _apply_append(new_state, path, op_spec["value"]) + elif op == "remove": + _apply_remove(new_state, path, op_spec["index"]) + else: + errors.append(f"Unknown operation: {op}") + else: + _apply_set(new_state, path, op_spec) + except ValidationError as e: + errors.append(str(e)) + if errors: + return None, errors + return new_state, [] +``` + +**Refactor:** вынести общую логику навигации по path в `_resolve_path()`, переиспользовать в `apply_patch` и `validate_state`. + +#### Пример 2: orchestrator Phase 1 integration test + +```python +# tests/integration/flows/test_orchestrator_flow.py +import pytest + +@pytest.mark.asyncio +async def test_orchestrator_phase1_calls_tools_and_submits_plan( + api_client, sample_world, mock_llm_client, auth_token +): + """ + Phase 1 должна: + 1. Вызвать LLM минимум 1 раз. + 2. LLM вызывает env_update (из mocking replay). + 3. Завершается submit_plan. + """ + world_id = await create_world(api_client, auth_token, sample_world) + + response = await api_client.post( + f"/api/sessions/worlds/{world_id}/iterate", + json={"action": "Я открываю дверь", "action_source": "custom"}, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert response.status_code == 202 + stream_url = response.json()["stream_url"] + + events = await collect_sse_events(api_client, stream_url, auth_token) + + # Проверки + assert any(e["event"] == "phase_start" and e["data"]["phase"] == 1 for e in events) + assert any(e["event"] == "tool_call" and e["data"]["tool"] == "env_update" for e in events) + assert any(e["event"] == "phase_end" and e["data"]["phase"] == 1 for e in events) + assert events[-1]["event"] == "done" +``` + +### 14.5. Метрики покрытия + +- **Целевое покрытие:** ≥ 80% для `app/core/`, `app/engine/`, `app/api/`. ≥ 60% для `app/prompts/` (сложно тестировать промпты). +- **Команда:** `pytest --cov=app --cov-report=html --cov-fail-under=80`. +- **CI блокирует merge**, если coverage упал ниже порога. +- **Покрытие по типам:** + - Unit: 90%+ (быстрые, изолированные). + - Integration: 70%+ (через API + testcontainers). + - E2E: ключевые сценарии (создание мира, итерация, редактирование). + +### 14.6. Фронтенд-тесты + +- **vitest** для unit-тестов stores и утилит. +- **@testing-library/react** для component-тестов. +- **Playwright** для E2E (полный сценарий: логин → создание мира → итерация). + +```typescript +// frontend/src/stores/__tests__/sessionStore.test.ts +import { renderHook, act } from '@testing-library/react'; +import { useSessionStore } from '../sessionStore'; + +test('iterate updates isIterating flag during call', async () => { + const { result } = renderHook(() => useSessionStore()); + expect(result.current.isIterating).toBe(false); + + await act(async () => { + await result.current.iterate('Открыть дверь', 'custom'); + }); + + expect(result.current.isIterating).toBe(false); // после завершения + expect(result.current.steps).toHaveLength(1); +}); +``` + +### 14.7. Когда тесты не нужны + +- Pure data classes (Pydantic models без логики). +- Миграции Alembic (тестируются через `alembic upgrade head` в CI). +- UI-компоненты без логики (Button, Card). + +--- + +## 15. Нефункциональные требования + +### 15.1. Производительность + +| Метрика | Цель | Как измерять | +|---|---|---| +| Latency `GET /api/worlds` (p95) | < 200ms | Prometheus histogram | +| Latency `POST /api/sessions/.../iterate` (от запроса до `iteration_complete`) | < 30s (одна фаза LLM ~5s) | SSE timestamp diff | +| Latency LLM single call (p50 / p95) | 2s / 8s | `llm_call_logs.latency_ms` | +| Throughput orchestrator iterations per minute | ≥ 10 (один сервер) | counter запросов | +| DB pool size | 20 connections (default), настраивается | SQLAlchemy metrics | +| SSE max concurrent connections per server | 200 | uvicorn worker config | +| Frontend first contentful paint | < 1.5s | Lighthouse | +| Frontend time to interactive | < 3s | Lighthouse | + +**Оптимизации:** +- LLM streaming для Phase 2 — пользователь видит текст сразу, не ждёт завершения. +- Connection pool к БД — переиспользование соединений. +- Индексы на `worlds(owner_id, last_played_at)`, `entities(world_id, entity_type)` — критичны для списков. +- Pagination на всех list-эндпоинтах — не возвращать > 50 элементов за раз. +- Lazy loading фронтенда — code splitting по маршрутам. + +### 15.2. Безопасность + +| Требование | Реализация | +|---|---| +| **Аутентификация** | JWT (HS256), access token 24h, refresh token 7d. Blacklist refresh tokens при logout. | +| **Хеширование паролей** | bcrypt с cost factor 12. | +| **Authorisation** | RBAC: `user` / `admin`. Проверка `owner_id` на всех мутациях World/Entity. | +| **Валидация ввода** | Pydantic на всех API endpoints. JSON-schema на всех tool calls. | +| **SQL Injection** | Только SQLAlchemy parameterized queries. Никаких f-string SQL. | +| **XSS** | React по умолчанию экранирует. `dangerouslySetInnerHTML` запрещён без review. | +| **CSRF** | JWT в Authorization header (не cookie) — CSRF не применим. | +| **CORS** | `APP_CORS_ORIGINS` whitelist в .env. | +| **Rate limiting** | `RATE_LIMIT_PER_MINUTE=60` для обычных endpoints, `RATE_LIMIT_LLM_PER_MINUTE=10` для LLM-вызовов. Через `slowapi`. | +| **Secrets** | `.env` не коммитится. `.env.example` с placeholder значениями. API-ключи в `settings` шифруются на уровне приложения (Fernet). | +| **Audit log** | `llm_call_logs` — полный лог всех LLM-вызовов. `step_tool_calls` — аудит tool calls. | +| **Password policy** | ≥ 8 символов, минимум 1 буква + 1 цифра, blacklist top-1000 утечек. | +| **HTTPS** | nginx с Let's Encrypt в продакшене. HTTP→HTTPS redirect. | +| **Admin setup token rotation** | каждые 24 часа (cron task). | + +### 15.3. Наблюдаемость (Observability) + +**Логи:** +- Структурированный JSON через `structlog`. +- Уровни: DEBUG (dev), INFO (prod), WARNING, ERROR. +- Все LLM-вызовы логируются с `stage`, `world_id`, `step_id`, `latency_ms`, `tokens`, `status`. +- Все ошибки логируются со stacktrace. + +**Метрики (Prometheus):** +- `http_requests_total{method, path, status}` — counter. +- `http_request_duration_seconds{method, path}` — histogram. +- `llm_calls_total{stage, status}` — counter. +- `llm_call_duration_seconds{stage}` — histogram. +- `sse_active_connections` — gauge. +- `db_pool_size{used, total}` — gauge. + +**Tracing (опционально, OpenTelemetry):** +- span на каждый HTTP-запрос. +- span на каждый LLM-вызов с `traceparent` propagation. +- span на каждый tool call. + +**Dashboards (Grafana):** +- Overview: QPS, p95 latency, error rate, active users. +- LLM: calls/min, error rate, token usage, latency per stage. +- DB: pool usage, slow queries, connections. +- SSE: active connections, average stream duration. + +**Alerting:** +- LLM error rate > 10% за 5 минут → Slack alert. +- p95 latency > 10s → warning. +- DB pool > 80% → critical. +- Disk usage > 80% → warning. + +### 15.4. Scalability + +Текущая архитектура — **single-instance** (один backend, один db). Для масштабирования: + +- **Вертикально:** больше CPU/RAM на backend, больше CPU на db. +- **Горизонтально (stateless backend):** N backend instances за load balancer. SSE работает через sticky sessions (LB направляет запросы одного клиента на один backend). +- **DB:** read replicas для `GET` запросов. Write master для мутаций. Партиционирование `llm_call_logs` по `created_at` (monthly). +- **Cache (опционально):** Redis для: + - Idempotency-Key кеша. + - Rate limit counters. + - Session state (для быстрых `GET /api/sessions/.../state`). + +### 15.5. Backup & Recovery + +| Что | Частота | Хранение | RTO | RPO | +|---|---|---|---|---| +| PostgreSQL dump | ежедневно 3:00 | 30 дней локально + S3 | 1h | 24h | +| WAL archiving | непрерывно | 7 дней | 15min | 5min | +| Volume `data/` | ежедневно | 14 дней | 1h | 24h | +| Config (.env) | при изменении | git + secret manager | 5min | immediate | + +**Disaster recovery план:** +1. Поднять новый сервер с тем же docker-compose. +2. Восстановить БД из последнего дампа: `gunzip -c backup.sql.gz | psql`. +3. Применить pending migrations: `alembic upgrade head`. +4. Smoke-test: `GET /api/health` → 200. +5. Переключить DNS на новый сервер. + +### 15.6. Совместимость + +- **Браузеры:** Chrome 110+, Firefox 110+, Safari 16+, Edge 110+. +- **PostgreSQL:** 15+ (требуется для `gen_random_uuid()` по умолчанию). +- **Python:** 3.12+. +- **Node.js:** 20+ (LTS). + +--- + +## 16. Стратегия обработки ошибок + +### 16.1. Категории ошибок + +| Категория | Примеры | Где обрабатывается | UX | +|---|---|---|---| +| **LLM errors** | timeout, 5xx от провайдера, invalid JSON в ответе, hallucination (несуществующий tool) | `LlmClient` + retry в `orchestrator` | SSE `error` event + кнопка "Повторить" | +| **Tool validation errors** | `state_validator` отклонил patch, unknown entity_type, name_conflict | `ToolRegistry.execute` | Возвращается LLM как `tool_result(ok=false)`, LLM может исправиться | +| **DB errors** | constraint violation, connection lost, deadlock | SQLAlchemy + retry decorator | 500 Internal Error, логируется | +| **Auth errors** | expired token, invalid token, not owner | FastAPI middleware | 401/403 JSON response | +| **Rate limit** | превышен лимит | slowapi middleware | 429 с `Retry-After` header | +| **SSE disconnect** | клиент отвалился, network issue | `EventSource` auto-reconnect + `Last-Event-ID` | Прогресс-бар "Переподключение..." | +| **Validation errors** | Pydantic на API, JSON-schema на tools | FastAPI + custom exception handler | 400 с детальным списком ошибок | +| **Business logic errors** | мир в `status=draft`, нет админа, etc. | Engine layer | 422 с описанием | + +### 16.2. Retry-политики + +| Операция | Retry | Backoff | Fallback | +|---|---|---|---| +| LLM call | 3 attempts | exponential: 1s, 2s, 4s | SSE error, кнопка "Повторить" | +| DB transaction (deadlock) | 3 attempts | fixed 100ms | 500 Internal Error | +| Tool execution | 0 (LLM сама решает повторить) | — | — | +| SSE reconnect | ∞ (until user closes) | 1s, 2s, 5s, 10s, 30s | "Переподключение..." UI | +| Embeddings API | 2 attempts | 2s, 5s | Skip embedding, log warning | + +### 16.3. Стандартные коды ошибок API + +(см. [раздел 6.7](#67-стандартные-коды-ошибок)) + +### 16.4. Логирование ошибок + +```python +try: + result = await llm.complete(...) +except LLMTimeoutError as e: + logger.error("llm_timeout", + stage=stage, + world_id=str(world_id), + step_id=str(step_id), + timeout_seconds=settings["llm.timeout_seconds"], + error=str(e), + ) + await sse_emitter.emit("error", {"code": "llm_timeout", "message": "LLM не ответила вовремя"}) + raise +except Exception as e: + logger.exception("unexpected_error", stage=stage, error=str(e)) + await sse_emitter.emit("error", {"code": "internal_error", "message": "Непредвиденная ошибка"}) + raise +``` + +### 16.5. Fallback-стратегии + +| Сценарий | Fallback | +|---|---| +| LLM не вызвала `submit_plan` за лимит | ORC форсит завершение: собирает summary из последних tool_results | +| LLM не вызвала `submit_step` после 2 ретраев | SSE `error` с кодом `writer_no_submit`, step помечается `failed`, доступен retry | +| `state_validator` отклонил patch 3 раза подряд | ORC добавляет в system message "Ваши предыдущие изменения отклонены валидатором. Проверь аргументы." | +| Embeddings API недоступен | `rag_query` возвращает пустой список + warning в лог. `rag_add` сохраняет запись с `embedding_status='pending'`, фоновый индексатор `app/workers/embedding_indexer.py` повторит через 30 секунд. После 3 неудачных попыток запись переходит в `embedding_status='failed'`, доступна для ручного retry через `POST /api/admin/embeddings/retry-failed`. | +| Qdrant недоступен | `rag_query` возвращает пустой список + error в лог. `rag_add` сохраняет запись с `embedding_status='pending'`. Health-check `qdrant` в `/api/health` становится `false`. Фоновый воркер блокирует обработку новых записей до восстановления Qdrant. Игровая сессия продолжается — RAG опционален, не критичный. | +| Размерность эмбеддингов не совпадает с размерностью Qdrant-коллекции | Backend логирует error при upsert. Админу показывается баннер: «Размерность модели (X) не совпадает с коллекцией (Y). [Пересоздать коллекцию и переиндексировать]». Кнопка запускает `POST /api/admin/embeddings/recreate-collections` (с подтверждением). | +| DB connection lost mid-transaction | Transaction rollback, SSE `error` с `code=db_error`. Step помечается `failed`. | + +--- + +## 17. Roadmap реализации (по спринтам) + +Дорожная карта разбита на 8 спринтов по 2 недели. Каждый спринт заканчивается demoable deliverable. После каждого спринта — ретроспектива и обновление этого ТЗ если выявлены новые требования. + +### Sprint 1: Инфраструктура и БД (Foundation) + +**Цель:** Рабочий docker-compose с пустым приложением, развёрнутые PostgreSQL + Qdrant со всеми таблицами/коллекциями, миграции, seed-данные. + +**TDD-циклы:** +1. Test: `alembic upgrade head` создаёт все таблицы → Impl: миграция 001. (pgvector НЕ нужен.) +2. Test: `seed.py` заполняет `settings` → Impl: seed script (включая дефолтные `qdrant.*`, `embeddings.*`, `context.*` ключи). +3. Test: `GET /api/health` возвращает `{status:ok, db:true, qdrant:true, llm:false}` → Impl: health endpoint с проверкой Qdrant. +4. Test: `init_qdrant.py` создаёт коллекции `entities` и `story_entries` с payload-индексами → Impl: startup-хук. + +**Артефакты:** +- `docker-compose.yml` с 4 сервисами (db, qdrant, backend, frontend) работает `docker compose up`. +- Все таблицы созданы, Qdrant-коллекции `entities` и `story_entries` созданы с payload-индексами на `world_id`. +- `settings` заполнены дефолтами (`embeddings.provider=offline_hash`, `embeddings.dimension=256` для dev). +- 2 встроенных пресета (fantasy, sci-fi) в `world_presets`. + +**Приёмочные критерии:** +- `curl http://localhost/api/health` → 200 `{"status":"ok","db":true,"qdrant":true,"llm":false,"embeddings":true}`. +- `psql` показывает 10 таблиц (без pgvector extension). +- `curl http://localhost:6333/collections` возвращает обе коллекции. +- Coverage ≥ 80% на `app/migrations/` и `app/core/settings_service.py`. + +### Sprint 2: Auth + Admin + +**Цель:** Регистрация, вход, JWT, админ-панель настроек, создание первого админа. + +**TDD-циклы:** +1. Test: `POST /api/register/admin` с верным токеном создаёт админа → Impl. +2. Test: `POST /api/auth/login` по email возвращает JWT → Impl. +3. Test: `POST /api/auth/login` по username возвращает JWT → Impl. +4. Test: защищённый endpoint без token → 401. +5. Test: `GET /api/admin/settings` без admin → 403. +6. Test: `PATCH /api/admin/settings` обновляет значение → Impl. + +**Артефакты:** +- `/login`, `/register`, `/register/admin` страницы. +- `/admin` страница с формой настроек. +- Admin setup URL выводится в лог при старте. + +### Sprint 3: World Builder + +**Цель:** Создание мира из пресета или формы, генерация схем и environment. + +**TDD-циклы:** +1. Test: `POST /api/worlds` с `mode=form` создаёт World со `status=draft` → Impl. +2. Test: WorldBuilder генерирует `schemas` через mock LLM → Impl. +3. Test: SSE `world_schema_generated` эмитится → Impl. +4. Test: `state_validator.validate_world` принимает сгенерированный мир → Impl. +5. Test: WorldBuilder dogenerates entities → Impl. + +**Артефакты:** +- `/worlds/new` страница с выбором пресета/формы. +- SSE-стрим `world_builder` работает end-to-end. +- Минимум 1 replay-фикстура LLM-ответов для тестов. + +### Sprint 4: World Editor + +**Цель:** Редактирование мира через чат + ручные правки JSON. + +**TDD-циклы:** +1. Test: `POST /api/worlds/{id}/edit` запускает world_editor stream → Impl. +2. Test: `ask_user` tool блокирует поток до ответа → Impl. +3. Test: `propose_changes` возвращает diff → Impl. +4. Test: `POST .../apply` коммитит staging → Impl. +5. Test: `POST .../discard` откатывает staging → Impl. +6. Test: optimistic lock: PATCH с устаревшим `updated_at` → 409. + +**Артефакты:** +- `/worlds/:id/edit` страница с чатом + JSON-редактором. +- ClarificationModal компонент. +- Diff-viewer для `propose_changes`. + +### Sprint 5: Orchestrator (Phase 1 + 2) + +**Цель:** Игровая итерация с тремя фазами, без deferred triggers и summary. + +**TDD-циклы:** +1. Test: Phase 1 вызывает LLM, LLM вызывает `env_update`, завершается `submit_plan` → Impl. +2. Test: Phase 2 LLM вызывает `submit_step`, scene_text стримится → Impl. +3. Test: Phase 3 persist коммитит state, обновляет `current_time` → Impl. +4. Test: `POST /api/sessions/.../iterate` возвращает SSE URL → Impl. +5. Test: `POST /api/sessions/.../retry` повторяет последний шаг → Impl. +6. Test: `POST /api/sessions/.../rollback` откатывает шаг → Impl. + +**Артефакты:** +- `/worlds/:id/play` страница с чатом. +- ToolCallBubble компонент. +- Прогресс-бар фаз. +- Кнопки "Повторить", "Отменить", "Откатить". + +### Sprint 6: Фронтенд-polish + i18n + +**Цель:** Полный UI с локализацией, тёмной темой, responsive. + +**TDD-циклы:** +1. Test: переключение языка re-renderит UI без перезагрузки → Impl. +2. Test: тёмная тема применяется через Tailwind `dark:` → Impl. +3. Test: mobile layout <768px сворачивает sidebar → Impl. +4. Test: SSE reconnect восстанавливает стрим → Impl. + +**Артефакты:** +- `i18n/en.json` и `i18n/ru.json` заполнены. +- Тёмная/светлая тема. +- Mobile-first layout. +- E2E тесты на Playwright. + +### Sprint 7: RAG + Deferred Triggers + Summary + Context Optimization + +**Цель:** Полная функциональность Phase 3 + RAG на Qdrant + контекстная оптимизация. + +**TDD-циклы:** +1. Test: `rag_query` через Qdrant возвращает релевантные StoryEntry (с mock embeddings) → Impl (§11.1.4). +2. Test: `rag_add` создаёт StoryEntry + upsert-ит точку в Qdrant `story_entries` → Impl (§11.1.5). +3. Test: фоновый `embedding_indexer` подхватывает `embedding_status='pending'` и индексирует → Impl (§11.6.4). +4. Test: `build_embedder()` fallback-правило: если `embeddings.provider='openai'` и `api_url`/`api_key` пустые → берутся `llm.*` (§11.6.2) → Impl. +5. Test: `POST /api/admin/test/embeddings/probe-dimension` возвращает корректную dimension для mock-провайдера → Impl. +6. Test: `schedule_trigger` создаёт DeferredTrigger → Impl. +7. Test: Phase 3.1 запускает subagent для pending triggers → Impl. +8. Test: Phase 3.2 генерирует summary при превышении `context.compression_threshold_messages` → Impl. +9. Test: Phase 3.3 вызывает `suggest_actions` → Impl. +10. Test: контекстный менеджер `build_context()` возвращает messages, умещающиеся в бюджет токенов (с деградацией: recent → rag → summary) → Impl (§11.5). +11. Test: изоляция миров — `rag_query(world_id=A)` не возвращает результаты мира B (через payload-фильтр Qdrant) → Impl. +12. Test: удаление мира каскадно чистит точки в Qdrant (`_cleanup_qdrant`) → Impl (§11.1.6). + +**Артефакты:** +- `HashEmbedder` и `OpenAIEmbedder` реализации. +- `app/core/rag.py` — двухстадийный retrieval (Qdrant → PostgreSQL). +- `app/workers/embedding_indexer.py` — фоновый индексатор. +- `app/engine/context.py` — контекстный менеджер с бюджетом токенов и деградацией. +- Эндпоинты `/api/admin/test/llm`, `/test/llm-tools`, `/test/embeddings`, `/test/embeddings/probe-dimension` (см. §6.5). +- `run_subagent` tool работает. +- Summary в `story_entries` с `entry_type='summary'`. +- Admin-панель показывает логи LLM с фильтрами + диагностические кнопки на странице настроек (см. §12.4). + +### Sprint 8: Polish, Performance, Production-readiness + +**Цель:** Production-ready deploy, метрики, бэкапы, документация. + +**TDD-циклы:** +1. Test: Prometheus `/metrics` endpoint отдаёт метрики → Impl. +2. Test: backup script создаёт gzip dump → Impl. +3. Test: rate limiter возвращает 429 при превышении → Impl. +4. Test: load test (50 concurrent users) проходит без ошибок → Impl (Locust script). +5. Test: security scan (bandit, pip-audit, npm audit) без critical уязвимостей → Impl. + +**Артефакты:** +- Grafana dashboard JSON. +- Backup cron настроен. +- `docs/deployment.md` — пошаговый гайд. +- `docs/api.md` — автогенерация из OpenAPI. +- README.md с quickstart. + +### Приоритеты и зависимости + +```mermaid +graph TD + S1[Sprint 1: Foundation] --> S2[Sprint 2: Auth+Admin] + S2 --> S3[Sprint 3: World Builder] + S3 --> S4[Sprint 4: World Editor] + S3 --> S5[Sprint 5: Orchestrator P1+P2] + S4 --> S5 + S5 --> S6[Sprint 6: Frontend polish] + S5 --> S7[Sprint 7: RAG + Triggers + Summary] + S6 --> S8[Sprint 8: Production] + S7 --> S8 + + style S1 fill:#e8f5e9 + style S8 fill:#fff3e0 +``` + +**MVP (после Sprint 5):** игрок может создать мир, играть (без RAG и triggers), редактировать мир. Это демонстрируемый продукт. + +**Full release (после Sprint 8):** production-ready система со всеми фичами. + +--- + +## 18. Чек-листы и приёмочные критерии + +### 18.1. Definition of Done (DoD) для каждой фичи + +Фича считается завершённой только если **все** пункты выполнены: + +- [ ] Код написан по TDD (сначала failing test, потом реализация). +- [ ] Все unit-тесты проходят: `pytest tests/unit/ -v`. +- [ ] Все integration-тесты проходят: `pytest tests/integration/ -v`. +- [ ] Coverage на изменённых файлах ≥ 80%. +- [ ] Линтер проходит без ошибок: `ruff check app/ tests/`. +- [ ] Type-check проходит: `mypy app/`. +- [ ] Docstrings на всех новых public функциях (Google style). +- [ ] Если добавлена новая настройка — она есть в `settings` seed и в `.env.example`. +- [ ] Если добавлен новый API endpoint — он есть в OpenAPI схеме (`/api/openapi.json`). +- [ ] Если добавлен новый tool — он зарегистрирован в `ToolRegistry` и описан в этом ТЗ. +- [ ] Если добавлена новая БД-таблица — создана Alembic миграция с `upgrade()` и `downgrade()`. +- [ ] Если изменён SSE-протокол — обновлён раздел 7 этого ТЗ. +- [ ] Если изменён промпт — обновлён раздел 10. +- [ ] CHANGELOG.md обновлён. +- [ ] PR reviewed и approved. +- [ ] CI зелёный. + +### 18.2. Pre-merge чек-лист + +- [ ] `pytest --cov=app --cov-fail-under=80` → exit 0. +- [ ] `cd frontend && npm run lint && npm run typecheck && npm run test` → exit 0. +- [ ] `cd frontend && npx playwright test` → exit 0 (если затронут E2E). +- [ ] `docker compose build` → exit 0. +- [ ] `docker compose up` → приложение стартует, `GET /api/health` → 200. +- [ ] Smoke-тест вручную: создать мир → сделать итерацию → откатить. +- [ ] Нет новых `console.log` / `print` в коде (кроме dev-скриптов). +- [ ] Нет захардкоженных секретов (использовать `settings`). + +### 18.3. Pre-deploy чек-лист (staging → production) + +- [ ] Миграции применены на staging: `alembic upgrade head`. +- [ ] Rollback протестирован: `alembic downgrade -1` на staging. +- [ ] Backup сделан перед деплоем. +- [ ] `.env` production обновлён (если новые переменные). +- [ ] LLM endpoint доступен из production сервера (firewall). +- [ ] HTTPS сертификат валиден. +- [ ] Health-check после деплоя: `GET /api/health` → 200 с `llm:true`. +- [ ] Smoke-тест: логин → создать мир → итерация. +- [ ] Мониторинг: метрики идут в Prometheus, логи в centralized system. +- [ ] Команда уведомлена о деплое. + +### 18.4. Чек-лист безопасности (ежемесячный аудит) + +- [ ] `pip-audit` на backend dependencies → нет critical. +- [ ] `npm audit` на frontend dependencies → нет critical. +- [ ] `bandit -r app/` → нет high severity. +- [ ] Все пароли в `settings` зашифрованы (Fernet). +- [ ] JWT secret не утёк (проверка через git history). +- [ ] Admin setup token ротирован за последние 24h. +- [ ] Бэкапы восстанавливаемы (test restore на staging). +- [ ] CORS origins не содержит `*`. + +### 18.5. Чек-лист для ИИ-агента-разработчика + +Перед началом работы над любой задачей: + +- [ ] Прочитал этот ТЗ целиком (хотя бы разделы 1, 2, 3, и релевантные задаче). +- [ ] Прочитал `worklog.md` — что уже сделали другие агенты. +- [ ] Понял, в каком спринте задача и какие зависимости. +- [ ] Написал TODO-список для задачи. +- [ ] Для каждой подзадачи: RED (тест) → GREEN (реализация) → REFACTOR. +- [ ] После завершения — обновил `worklog.md` согласно шаблону. +- [ ] Если обнаружил неоднозначность в ТЗ — задал вопрос (не додумывал). + +### 18.6. Метрики успеха проекта + +| Метрика | Цель | Как измерять | +|---|---|---| +| Time to first iteration (от установки до первой игры) | < 30 минут | Manual test | +| Удержание (D7 retention) | ≥ 30% | Аналитика (когда будет) | +| Средняя длина сессии | ≥ 20 итераций | `steps` count per world | +| LLM error rate | < 5% | `llm_call_logs` WHERE status != 'ok' | +| Crash rate | < 1% итераций | `steps` WHERE status='failed' | +| Coverage | ≥ 80% | CI | +| p95 latency iteration | < 30s | SSE timestamps | + +--- + +## Приложение A. Ссылки и референсы + +- **OpenAI function calling docs:** https://platform.openai.com/docs/guides/function-calling +- **Qdrant docs:** https://qdrant.tech/documentation/ +- **Qdrant Python client:** https://github.com/qdrant/qdrant-client +- **Qdrant payload filters:** https://qdrant.tech/documentation/concepts/filtering/ +- **Qdrant snapshots / backup:** https://qdrant.tech/documentation/concepts/snapshots/ +- **FastAPI docs:** https://fastapi.tiangolo.com/ +- **SQLAlchemy 2.x async:** https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html +- **sse-starlette:** https://github.com/sysid/sse-starlette +- **Alembic:** https://alembic.sqlalchemy.org/ +- **TDD (Martin Fowler):** https://martinfowler.com/bliki/TestDrivenDevelopment.html +- **structlog:** https://www.structlog.org/ +- **tiktoken (оценка токенов):** https://github.com/openai/tiktoken + +## Приложение B. Шаблон worklog.md записи + +```markdown +--- +Task ID: +Agent: +Task: <краткое описание задачи> + +Work Log: +- Прочитал ТЗ разделы 5, 8, 9.3 +- Прочитал worklog — предыдущие задачи S5-1, S5-2 завершены +- Написал failing тест test_orchestrator_phase1_calls_env_update +- Реализовал минимально: ToolRegistry.execute, env_update tool +- Тест зелёный +- Рефакторинг: вынес _resolve_path в общий utils +- Все тесты зелёные, coverage 85% + +Stage Summary: +- Реализован Phase 1 orchestrator с tool execution loop +- Добавлен MockLLMClient для replay-тестирования +- Создана replay-фикстура orchestrator_basic.json +- Изменена схема: добавлена step_tool_calls таблица (миграция 003) +- Open вопрос: нужно ли ограничение на количество одновременных tool calls в одном response? +``` + +## Приложение C. Глоссарий сокращений + +| Сокращение | Расшифровка | +|---|---| +| TZ | Техническое задание | +| TDD | Test-Driven Development | +| GM | Game Master | +| SSE | Server-Sent Events | +| RAG | Retrieval-Augmented Generation | +| JWT | JSON Web Token | +| RBAC | Role-Based Access Control | +| NFR | Non-Functional Requirement | +| DoD | Definition of Done | +| ADR | Architecture Decision Record | +| ORM | Object-Relational Mapping | +| ASGI | Asynchronous Server Gateway Interface | +| SPA | Single Page Application | +| UX | User Experience | +| FK | Foreign Key | +| PK | Primary Key | +| UK | Unique Key | diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index ee2535a..0000000 --- a/frontend/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -# Build stage -FROM node:20-alpine AS builder -WORKDIR /app -COPY package.json package-lock.json* ./ -RUN npm install -COPY . . -RUN npm run build - -# Dev stage (default target) -FROM node:20-alpine AS dev -WORKDIR /app -COPY package.json package-lock.json* ./ -RUN npm install -COPY . . -EXPOSE 5173 -CMD ["npm", "run", "dev"] - -# Prod stage -FROM nginx:alpine AS prod -COPY --from=builder /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html index 1ba4c7f..7b92f23 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,13 @@ - + - - + - AI RPG + + AI-RPG - +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e2b57b3..141b4e4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,39 +1,50 @@ { "name": "ai-rpg-frontend", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-rpg-frontend", - "version": "0.1.0", + "version": "1.0.0", "dependencies": { - "@microsoft/fetch-event-source": "^2.0.1", - "axios": "^1.7.7", "clsx": "^2.1.1", - "i18next": "^23.15.1", + "i18next": "^23.11.5", "i18next-browser-languagedetector": "^8.0.0", - "lucide-react": "^0.445.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-i18next": "^15.0.2", - "react-markdown": "^9.0.1", - "react-router-dom": "^6.26.2", - "tailwind-merge": "^2.5.2", - "zustand": "^4.5.5" + "react-i18next": "^14.1.2", + "react-router-dom": "^6.23.1", + "tailwind-merge": "^2.3.0", + "zustand": "^4.5.2" }, "devDependencies": { - "@types/node": "^22.7.4", - "@types/react": "^18.3.11", + "@testing-library/jest-dom": "^6.4.5", + "@testing-library/react": "^15.0.7", + "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.2", - "autoprefixer": "^10.4.20", - "postcss": "^8.4.47", - "tailwindcss": "^3.4.13", - "typescript": "^5.6.2", - "vite": "^5.4.8" + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.19", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.7", + "jsdom": "^24.1.0", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vitest": "^1.6.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -47,6 +58,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -103,6 +135,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", @@ -137,6 +179,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", @@ -338,6 +390,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -729,6 +896,168 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -779,12 +1108,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@microsoft/fetch-event-source": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", - "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", - "license": "MIT" - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -840,9 +1163,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", - "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", "cpu": [ "arm" ], @@ -854,9 +1177,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", - "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", "cpu": [ "arm64" ], @@ -868,9 +1191,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", - "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", "cpu": [ "arm64" ], @@ -882,9 +1205,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", - "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", "cpu": [ "x64" ], @@ -896,9 +1219,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", - "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", "cpu": [ "arm64" ], @@ -910,9 +1233,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", - "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", "cpu": [ "x64" ], @@ -924,9 +1247,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", - "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", "cpu": [ "arm" ], @@ -941,9 +1264,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", - "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", "cpu": [ "arm" ], @@ -958,9 +1281,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", - "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", "cpu": [ "arm64" ], @@ -975,9 +1298,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", - "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", "cpu": [ "arm64" ], @@ -992,9 +1315,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", - "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", "cpu": [ "loong64" ], @@ -1009,9 +1332,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", - "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", "cpu": [ "loong64" ], @@ -1026,9 +1349,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", - "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", "cpu": [ "ppc64" ], @@ -1043,9 +1366,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", - "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", "cpu": [ "ppc64" ], @@ -1060,9 +1383,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", - "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", "cpu": [ "riscv64" ], @@ -1077,9 +1400,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", - "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", "cpu": [ "riscv64" ], @@ -1094,9 +1417,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", - "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", "cpu": [ "s390x" ], @@ -1111,9 +1434,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", - "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", "cpu": [ "x64" ], @@ -1128,9 +1451,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", - "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", "cpu": [ "x64" ], @@ -1145,9 +1468,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", - "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", "cpu": [ "x64" ], @@ -1159,9 +1482,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", - "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", "cpu": [ "arm64" ], @@ -1173,9 +1496,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", - "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", "cpu": [ "arm64" ], @@ -1187,9 +1510,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", - "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", "cpu": [ "ia32" ], @@ -1201,9 +1524,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", - "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", "cpu": [ "x64" ], @@ -1215,9 +1538,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", - "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", "cpu": [ "x64" ], @@ -1228,6 +1551,102 @@ "win32" ] }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-15.0.7.tgz", + "integrity": "sha512-cg0RvEdD1TIhhkm1IeYMQxrzy0MtUNfa3minv4MjbgcYzJAZ7yD0i0lwoPOTPr+INtiXFezt2o8xMSnyHhEn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^10.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1273,74 +1692,25 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/debug": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", - "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "license": "MIT" - }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", - "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } + "license": "MIT" }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1357,16 +1727,204 @@ "@types/react": "^18.0.0" } }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, "node_modules/@ungap/structured-clone": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-react": { @@ -1390,16 +1948,266 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, "license": "MIT", "dependencies": { - "debug": "4" + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" }, "engines": { - "node": ">= 6.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@vitest/snapshot/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/snapshot/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@vitest/utils/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/any-promise": { @@ -1430,10 +2238,48 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, "node_modules/autoprefixer": { @@ -1473,27 +2319,12 @@ "postcss": "^8.1.0" } }, - "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" }, "node_modules/baseline-browser-mapping": { "version": "2.10.38", @@ -1521,6 +2352,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1568,10 +2409,21 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1581,6 +2433,16 @@ "node": ">= 0.4" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1612,54 +2474,53 @@ ], "license": "CC-BY-4.0" }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" } }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" } }, "node_modules/chokidar": { @@ -1709,10 +2570,31 @@ "node": ">=6" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -1721,16 +2603,6 @@ "node": ">= 0.8" } }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -1741,6 +2613,20 @@ "node": ">= 6" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1748,6 +2634,28 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1761,16 +2669,53 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1784,23 +2729,38 @@ } } }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, "license": "MIT", "dependencies": { - "character-entities": "^2.0.0" + "type-detect": "^4.0.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -1810,24 +2770,12 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1835,6 +2783,29 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -1842,10 +2813,31 @@ "dev": true, "license": "MIT" }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -1857,16 +2849,30 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.375", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", - "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", + "version": "1.5.376", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", + "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1876,6 +2882,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1885,6 +2892,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -1897,6 +2905,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1957,20 +2966,256 @@ "node": ">=6" } }, - "node_modules/estree-util-is-identifier-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", - "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", + "engines": { + "node": ">=10" + }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -2003,6 +3248,20 @@ "node": ">= 6" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2013,6 +3272,19 @@ "reusify": "^1.0.4" } }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2026,30 +3298,50 @@ "node": ">=8" } }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=4.0" + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -2076,6 +3368,13 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2095,6 +3394,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2110,10 +3410,21 @@ "node": ">=6.9.0" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -2138,6 +3449,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -2147,6 +3459,41 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2160,10 +3507,72 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2172,10 +3581,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2188,6 +3615,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -2203,6 +3631,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2211,44 +3640,17 @@ "node": ">= 0.4" } }, - "node_modules/hast-util-to-jsx-runtime": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", - "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-js": "^1.0.0", - "unist-util-position": "^5.0.0", - "vfile-message": "^4.0.0" + "whatwg-encoding": "^3.1.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=18" } }, "node_modules/html-parse-stringify": { @@ -2260,27 +3662,42 @@ "void-elements": "3.1.0" } }, - "node_modules/html-url-attributes": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", - "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" } }, "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, "license": "MIT", "dependencies": { - "agent-base": "6", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { - "node": ">= 6" + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" } }, "node_modules/i18next": { @@ -2315,36 +3732,85 @@ "@babel/runtime": "^7.23.2" } }, - "node_modules/inline-style-parser": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", - "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", - "license": "MIT" - }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "license": "MIT", "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2374,16 +3840,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2407,16 +3863,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2427,18 +3873,43 @@ "node": ">=0.12.0" } }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -2455,6 +3926,70 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2468,6 +4003,27 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2481,6 +4037,30 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2501,16 +4081,46 @@ "dev": true, "license": "MIT" }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/antfu" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2523,6 +4133,16 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2533,176 +4153,42 @@ "yallist": "^3.0.2" } }, - "node_modules/lucide-react": { - "version": "0.445.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.445.0.tgz", - "integrity": "sha512-YrLf3aAHvmd4dZ8ot+mMdNFrFpJD7YRwQ2pUcBhgqbmxtrMP4xDzIorcj+8y+6kpuXBF4JB0NOCTUWIYetJjgA==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", - "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-expression": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", - "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-jsx": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", - "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdxjs-esm": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", - "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", @@ -2714,448 +4200,6 @@ "node": ">= 8" } }, - "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -3174,6 +4218,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -3183,6 +4228,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -3191,10 +4237,70 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -3210,9 +4316,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.13", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", - "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "version": "3.3.14", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz", + "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", "dev": true, "funding": [ { @@ -3228,6 +4334,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.48", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", @@ -3248,6 +4361,42 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3268,30 +4417,137 @@ "node": ">= 6" } }, - "node_modules/parse-entities": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", - "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", - "license": "MIT", + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", "dependencies": { - "@types/unist": "^2.0.0", - "character-entities-legacy": "^3.0.0", - "character-reference-invalid": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0", - "is-hexadecimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "wrappy": "1" } }, - "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } }, "node_modules/path-parse": { "version": "1.0.7", @@ -3300,6 +4556,33 @@ "dev": true, "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3340,6 +4623,25 @@ "node": ">= 6" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -3503,25 +4805,74 @@ "dev": true, "license": "MIT" }, - "node_modules/property-information": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", - "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": ">= 0.8.0" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3569,18 +4920,17 @@ } }, "node_modules/react-i18next": { - "version": "15.7.4", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", - "integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==", + "version": "14.1.3", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.3.tgz", + "integrity": "sha512-wZnpfunU6UIAiJ+bxwOiTmBOAaB14ha97MjOEnLGac2RJ+h/maIYXZuTHlmyqQVX1UVHmU1YDTQ5vxLmwfXTjw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.6", + "@babel/runtime": "^7.23.9", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { - "i18next": ">= 23.4.0", - "react": ">= 16.8.0", - "typescript": "^5" + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" }, "peerDependenciesMeta": { "react-dom": { @@ -3588,38 +4938,15 @@ }, "react-native": { "optional": true - }, - "typescript": { - "optional": true } } }, - "node_modules/react-markdown": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", - "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "hast-util-to-jsx-runtime": "^2.0.0", - "html-url-attributes": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.0.0", - "unified": "^11.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=18", - "react": ">=18" - } + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.17.0", @@ -3686,38 +5013,26 @@ "node": ">=8.10.0" } }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, "license": "MIT", "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=8" } }, - "node_modules/remark-rehype": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", - "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "mdast-util-to-hast": "^13.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" }, "node_modules/resolve": { "version": "1.22.12", @@ -3741,6 +5056,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -3752,10 +5077,27 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", - "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "dev": true, "license": "MIT", "dependencies": { @@ -3769,34 +5111,41 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.0", - "@rollup/rollup-android-arm64": "4.62.0", - "@rollup/rollup-darwin-arm64": "4.62.0", - "@rollup/rollup-darwin-x64": "4.62.0", - "@rollup/rollup-freebsd-arm64": "4.62.0", - "@rollup/rollup-freebsd-x64": "4.62.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", - "@rollup/rollup-linux-arm-musleabihf": "4.62.0", - "@rollup/rollup-linux-arm64-gnu": "4.62.0", - "@rollup/rollup-linux-arm64-musl": "4.62.0", - "@rollup/rollup-linux-loong64-gnu": "4.62.0", - "@rollup/rollup-linux-loong64-musl": "4.62.0", - "@rollup/rollup-linux-ppc64-gnu": "4.62.0", - "@rollup/rollup-linux-ppc64-musl": "4.62.0", - "@rollup/rollup-linux-riscv64-gnu": "4.62.0", - "@rollup/rollup-linux-riscv64-musl": "4.62.0", - "@rollup/rollup-linux-s390x-gnu": "4.62.0", - "@rollup/rollup-linux-x64-gnu": "4.62.0", - "@rollup/rollup-linux-x64-musl": "4.62.0", - "@rollup/rollup-openbsd-x64": "4.62.0", - "@rollup/rollup-openharmony-arm64": "4.62.0", - "@rollup/rollup-win32-arm64-msvc": "4.62.0", - "@rollup/rollup-win32-ia32-msvc": "4.62.0", - "@rollup/rollup-win32-x64-gnu": "4.62.0", - "@rollup/rollup-win32-x64-msvc": "4.62.0", + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -3821,6 +5170,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -3831,13 +5200,69 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/source-map-js": { @@ -3850,48 +5275,92 @@ "node": ">=0.10.0" } }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/style-to-js": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", - "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, "license": "MIT", "dependencies": { - "style-to-object": "1.0.14" + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/style-to-object": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", - "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, "license": "MIT", "dependencies": { - "inline-style-parser": "0.2.7" + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" } }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3915,6 +5384,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -3928,6 +5410,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", @@ -3976,6 +5465,13 @@ "node": ">=14.0.0" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -3999,6 +5495,13 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -4047,6 +5550,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4060,24 +5583,46 @@ "node": ">=8.0" } }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" } }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" } }, "node_modules/ts-interface-checker": { @@ -4087,11 +5632,47 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -4101,98 +5682,21 @@ "node": ">=14.17" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "dev": true, "license": "MIT" }, - "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">= 4.0.0" } }, "node_modules/update-browserslist-db": { @@ -4226,6 +5730,27 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -4242,34 +5767,6 @@ "dev": true, "license": "MIT" }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -4330,6 +5827,95 @@ } } }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -4339,6 +5925,156 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4346,6 +6082,19 @@ "dev": true, "license": "ISC" }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", @@ -4373,16 +6122,6 @@ "optional": true } } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } } } } diff --git a/frontend/package.json b/frontend/package.json index a196297..8b45e8a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,38 +1,44 @@ { "name": "ai-rpg-frontend", - "version": "0.1.0", "private": true, + "version": "1.0.0", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0 --port 5173", "build": "tsc -b && vite build", - "preview": "vite preview --host 0.0.0.0 --port 8080", - "lint": "eslint . --ext ts,tsx" + "preview": "vite preview --host 0.0.0.0 --port 5173", + "lint": "eslint src --ext ts,tsx --max-warnings 0", + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { - "@microsoft/fetch-event-source": "^2.0.1", - "axios": "^1.7.7", - "clsx": "^2.1.1", - "i18next": "^23.15.1", - "i18next-browser-languagedetector": "^8.0.0", - "lucide-react": "^0.445.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-i18next": "^15.0.2", - "react-router-dom": "^6.26.2", - "react-markdown": "^9.0.1", - "tailwind-merge": "^2.5.2", - "zustand": "^4.5.5" + "react-router-dom": "^6.23.1", + "zustand": "^4.5.2", + "react-i18next": "^14.1.2", + "i18next": "^23.11.5", + "i18next-browser-languagedetector": "^8.0.0", + "clsx": "^2.1.1", + "tailwind-merge": "^2.3.0" }, "devDependencies": { - "@types/node": "^22.7.4", - "@types/react": "^18.3.11", + "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.2", - "autoprefixer": "^10.4.20", - "postcss": "^8.4.47", - "tailwindcss": "^3.4.13", - "typescript": "^5.6.2", - "vite": "^5.4.8" + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5", + "vite": "^5.2.11", + "eslint": "^8.57.0", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.7", + "vitest": "^1.6.0", + "@testing-library/react": "^15.0.7", + "@testing-library/jest-dom": "^6.4.5", + "jsdom": "^24.1.0" } } diff --git a/frontend/public/.gitkeep b/frontend/public/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/public/logo.png b/frontend/public/icon.png similarity index 100% rename from frontend/public/logo.png rename to frontend/public/icon.png diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 99acaea..8d77684 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,102 +1,160 @@ import { useEffect } from "react"; -import { Routes, Route, Navigate } from "react-router-dom"; -import { useAuthStore } from "@/store/auth"; -import { useUiStore } from "@/store/ui"; +import { + BrowserRouter, + Routes, + Route, + Navigate, + useLocation, +} from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useAuthStore } from "@/stores/authStore"; import { Navbar } from "@/components/ui/Navbar"; -import { HomePage } from "@/pages/HomePage"; +import { ToastViewport } from "@/components/ui/Toast"; +import { ProtectedRoute } from "@/components/auth/ProtectedRoute"; import { LoginPage } from "@/pages/LoginPage"; import { RegisterPage } from "@/pages/RegisterPage"; -import { AdminSetupPage } from "@/pages/AdminSetupPage"; -import { DashboardPage } from "@/pages/DashboardPage"; -import { WorldCreatePage } from "@/pages/WorldCreatePage"; +import { AdminRegisterPage } from "@/pages/AdminRegisterPage"; +import { WorldsListPage } from "@/pages/WorldsListPage"; import { WorldBuilderPage } from "@/pages/WorldBuilderPage"; import { WorldEditPage } from "@/pages/WorldEditPage"; -import { SessionPage } from "@/pages/SessionPage"; -import { AdminPanelPage } from "@/pages/AdminPanelPage"; - -function PrivateRoute({ children }: { children: JSX.Element }) { - const token = useAuthStore((s) => s.token); - if (!token) return ; - return children; -} - -function AdminRoute({ children }: { children: JSX.Element }) { - const { token, user } = useAuthStore(); - if (!token) return ; - if (!user?.is_admin) return ; - return children; -} - -export default function App() { - // Load public UI settings (logo URL, etc.) once on app boot. These are - // unauthenticated and cached by the api layer, so subsequent navigations - // do not re-fetch. The admin panel calls load(true) after saving to - // pick up a new logo URL without a full page reload. - const loadUi = useUiStore((s) => s.load); - useEffect(() => { - loadUi(); - }, [loadUi]); +import { PlayPage } from "@/pages/PlayPage"; +import { AdminPage } from "@/pages/AdminPage"; +function Layout({ children }: { children: React.ReactNode }) { return ( -
+
-
- - } /> - } /> - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - -
+ {children} +
); } + +function RootRedirect() { + const status = useAuthStore((s) => s.status); + const user = useAuthStore((s) => s.user); + if (status === "authenticated" && user) return ; + return ; +} + +function PublicOnly({ children }: { children: React.ReactNode }) { + const status = useAuthStore((s) => s.status); + const user = useAuthStore((s) => s.user); + const location = useLocation(); + if (status === "authenticated" && user) { + const from = (location.state as { from?: string } | null)?.from; + return ; + } + return <>{children}; +} + +function ScrollToTop() { + const { pathname } = useLocation(); + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + return null; +} + +export default function App() { + const { t } = useTranslation(); + useEffect(() => { + document.title = t("common.app_name"); + }, [t]); + + return ( + + + + } /> + + {/* Public auth routes — full layout but no navbar redirect */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + {/* Protected routes */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + {/* Fallback */} + } /> + + + ); +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts deleted file mode 100644 index 790ba04..0000000 --- a/frontend/src/api/index.ts +++ /dev/null @@ -1,269 +0,0 @@ -import axios, { AxiosError } from "axios"; -import type { - GlossaryEntry, - LlmLog, - Message, - Preset, - Session, - SettingsOut, - TokenOut, - Trigger, - User, - World, - WorldBuilderReply, -} from "@/types"; -import { useAuthStore } from "@/store/auth"; - -const API_BASE = "/api"; - -const api = axios.create({ - baseURL: API_BASE, - headers: { "Content-Type": "application/json" }, -}); - -// Inject auth token -api.interceptors.request.use((config) => { - const token = useAuthStore.getState().token; - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; -}); - -// Auto-logout on 401 -api.interceptors.response.use( - (r) => r, - (err: AxiosError) => { - if (err.response?.status === 401) { - useAuthStore.getState().logout(); - } - return Promise.reject(err); - } -); - -export const authApi = { - register: async (email: string, username: string, password: string): Promise => { - const { data } = await api.post("/auth/register", { email, username, password }); - return data; - }, - login: async (login: string, password: string): Promise => { - // `login` accepts either email or username. - const { data } = await api.post("/auth/login", { login, password }); - return data; - }, - me: async (): Promise => { - const { data } = await api.get("/auth/me"); - return data; - }, - adminSetup: async (token: string, email: string, username: string, password: string): Promise => { - const { data } = await api.post("/auth/admin-setup", { token, email, username, password }); - return data; - }, -}; - -export const adminApi = { - getSettings: async (): Promise => { - const { data } = await api.get("/admin/settings"); - return data; - }, - updateSettings: async (values: Record): Promise => { - const { data } = await api.put("/admin/settings", { values }); - return data; - }, - testEmbeddings: async (overrides?: Record): Promise<{ - ok: boolean; - provider?: string; - base_url?: string; - model?: string; - dim?: number; - sample_norm?: number; - error?: string; - }> => { - const { data } = await api.post("/admin/embeddings/test", { overrides: overrides || {} }); - return data; - }, - testLlm: async (overrides?: Record): Promise<{ - ok: boolean; - base_url: string; - model: string; - dns_resolved?: boolean; - resolved_addrs?: string[]; - tcp_connect_ok?: boolean; - models_endpoint_status?: number; - available_models?: string[]; - chat_endpoint_status?: number; - latency_ms?: number; - response_preview?: string; - usage?: any; - error?: string; - error_type?: string; - }> => { - const { data } = await api.post("/admin/llm/test", { overrides: overrides || {} }); - return data; - }, - testLlmTools: async (overrides?: Record): Promise<{ - ok: boolean; - base_url: string; - model: string; - tool_calls_returned: boolean; - tool_call_name?: string; - tool_call_args?: any; - text?: string; - raw_tool_calls?: any[]; - http_status?: number; - latency_ms?: number; - usage?: any; - error?: string; - error_type?: string; - }> => { - const { data } = await api.post("/admin/llm/test-tools", { overrides: overrides || {} }); - return data; - }, - listLlmLogs: async (limit = 50, offset = 0): Promise => { - const { data } = await api.get(`/admin/llm-logs?limit=${limit}&offset=${offset}`); - return data; - }, - getLlmLog: async (id: string): Promise => { - const { data } = await api.get(`/admin/llm-logs/${id}`); - return data; - }, - listUsers: async (): Promise => { - const { data } = await api.get("/admin/users"); - return data; - }, - setUserActive: async (userId: string, isActive: boolean): Promise => { - const { data } = await api.post(`/admin/users/${userId}/set-active`, { is_active: isActive }); - return data; - }, -}; - -export const presetsApi = { - list: async (language?: string): Promise => { - const url = language ? `/presets?language=${language}` : "/presets"; - const { data } = await api.get(url); - return data; - }, - get: async (id: string): Promise => { - const { data } = await api.get(`/presets/${id}`); - return data; - }, - create: async (payload: Partial): Promise => { - const { data } = await api.post("/presets", payload); - return data; - }, -}; - -export const worldsApi = { - list: async (): Promise => { - const { data } = await api.get("/worlds"); - return data; - }, - get: async (id: string): Promise => { - const { data } = await api.get(`/worlds/${id}`); - return data; - }, - create: async (name: string, language: string, preset_id?: string): Promise => { - const { data } = await api.post("/worlds", { name, language, preset_id }); - return data; - }, - update: async (id: string, payload: Partial): Promise => { - const { data } = await api.patch(`/worlds/${id}`, payload); - return data; - }, - delete: async (id: string): Promise => { - await api.delete(`/worlds/${id}`); - }, - builderStart: async (payload: { - world_name: string; - language: string; - preset_id?: string; - setting_brief: string; - character_brief: string; - rules_brief: string; - notes: string; - }): Promise => { - const { data } = await api.post("/worlds/builder/start", payload); - return data; - }, - builderContinue: async (session_id: string, message: string): Promise => { - const { data } = await api.post("/worlds/builder/continue", { session_id, message }); - return data; - }, - builderCommit: async (session_id: string, name?: string): Promise => { - const { data } = await api.post("/worlds/builder/commit", { session_id, name }); - return data; - }, -}; - -export const sessionsApi = { - list: async (): Promise => { - const { data } = await api.get("/sessions"); - return data; - }, - create: async (world_id: string, title?: string): Promise => { - const { data } = await api.post("/sessions", { world_id, title }); - return data; - }, - get: async (id: string): Promise => { - const { data } = await api.get(`/sessions/${id}`); - return data; - }, - listMessages: async (id: string, includeHidden = false): Promise => { - const { data } = await api.get(`/sessions/${id}/messages?include_hidden=${includeHidden}`); - return data; - }, - delete: async (id: string): Promise => { - await api.delete(`/sessions/${id}`); - }, -}; - -export const miscApi = { - listGlossary: async (worldId: string, kind?: string): Promise => { - const url = kind ? `/worlds/${worldId}/glossary?kind=${kind}` : `/worlds/${worldId}/glossary`; - const { data } = await api.get(url); - return data; - }, - listTriggers: async (sessionId: string, includeFired = true): Promise => { - const { data } = await api.get(`/sessions/${sessionId}/triggers?include_fired=${includeFired}`); - return data; - }, -}; - -// Public UI settings — no auth required. Used on login/register/home pages -// to render branding (logo, eventually theme). Caches the result in-process -// so multiple components can call getPublicSettings() without re-fetching. -export type PublicUiSettings = { - logo_url?: string; -}; - -let _publicSettingsCache: PublicUiSettings | null = null; -let _publicSettingsPromise: Promise | null = null; - -export const uiApi = { - /** Fetch public UI settings (logo URL, etc.). Cached after first call. */ - getPublicSettings: async (force = false): Promise => { - if (_publicSettingsCache && !force) return _publicSettingsCache; - if (_publicSettingsPromise && !force) return _publicSettingsPromise; - _publicSettingsPromise = (async () => { - try { - const { data } = await api.get("/settings/public"); - _publicSettingsCache = { - logo_url: data["ui.logo_url"] || "/logo.png", - }; - } catch { - _publicSettingsCache = { logo_url: "/logo.png" }; - } finally { - _publicSettingsPromise = null; - } - return _publicSettingsCache; - })(); - return _publicSettingsPromise; - }, - /** Reset the in-memory cache. Call after admin saves new ui.logo_url. */ - resetCache: () => { - _publicSettingsCache = null; - _publicSettingsPromise = null; - }, -}; - -export const SSE_ENDPOINT = "/api/sessions"; diff --git a/frontend/src/components/admin/IconsPanel.tsx b/frontend/src/components/admin/IconsPanel.tsx new file mode 100644 index 0000000..b935d9f --- /dev/null +++ b/frontend/src/components/admin/IconsPanel.tsx @@ -0,0 +1,78 @@ +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AdminApi } from "@/lib/api"; +import { useToastStore } from "@/stores/toastStore"; +import type { UploadIconResult } from "@/types"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { Spinner } from "@/components/ui/Spinner"; + +type IconKind = "favicon" | "logo" | "og_image"; + +export function IconsPanel() { + const { t } = useTranslation(); + return ( +
+ + + +
+ ); +} + +function IconUploadCard({ kind, title }: { kind: IconKind; title: string }) { + const { t } = useTranslation(); + const pushToast = useToastStore((s) => s.push); + const fileRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [result, setResult] = useState(null); + + const onFile = async (file: File) => { + setUploading(true); + try { + const r = await AdminApi.uploadIcon(file, kind); + setResult(r); + pushToast("success", t("admin.icons_uploaded")); + } catch (err) { + pushToast("error", err instanceof Error ? err.message : t("admin.icons_upload_failed")); + } finally { + setUploading(false); + } + }; + + return ( + + { + const f = e.target.files?.[0]; + if (f) void onFile(f); + e.target.value = ""; + }} + /> +
+ + {uploading && } +
+ {result && ( +
+ {result.url && ( +

+ URL: {result.url} +

+ )} +

Size: {result.size_bytes} bytes

+ {result.url && (kind === "favicon" || kind === "logo" || kind === "og_image") && ( + // eslint-disable-next-line @next/next/no-img-element + {kind} + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/admin/LlmLogsTable.tsx b/frontend/src/components/admin/LlmLogsTable.tsx new file mode 100644 index 0000000..d48b741 --- /dev/null +++ b/frontend/src/components/admin/LlmLogsTable.tsx @@ -0,0 +1,256 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AdminApi } from "@/lib/api"; +import { useToastStore } from "@/stores/toastStore"; +import type { LlmLog, LlmLogDetail, Paginated } from "@/types"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { Input } from "@/components/ui/Input"; +import { Modal } from "@/components/ui/Modal"; +import { Spinner } from "@/components/ui/Spinner"; + +export function LlmLogsTable() { + const { t } = useTranslation(); + const pushToast = useToastStore((s) => s.push); + + const [data, setData] = useState | null>(null); + const [loading, setLoading] = useState(true); + const [filters, setFilters] = useState({ world_id: "", stage: "", status_filter: "" }); + const [appliedFilters, setAppliedFilters] = useState(filters); + const [page, setPage] = useState(1); + const [perPage] = useState(20); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailOpen, setDetailOpen] = useState(false); + + const fetchLogs = useCallback(async () => { + setLoading(true); + try { + const res = await AdminApi.llmLogs({ + world_id: appliedFilters.world_id || undefined, + stage: appliedFilters.stage || undefined, + status_filter: appliedFilters.status_filter || undefined, + page, + per_page: perPage, + }); + setData(res); + } catch (err) { + pushToast("error", err instanceof Error ? err.message : "Failed"); + } finally { + setLoading(false); + } + }, [appliedFilters, page, perPage, pushToast]); + + useEffect(() => { + void fetchLogs(); + }, [fetchLogs]); + + const applyFilters = () => { + setAppliedFilters(filters); + setPage(1); + }; + + const openDetail = async (id: string) => { + setDetailOpen(true); + setDetailLoading(true); + try { + const d = await AdminApi.llmLog(id); + setDetail(d); + } catch (err) { + pushToast("error", err instanceof Error ? err.message : "Failed"); + } finally { + setDetailLoading(false); + } + }; + + return ( +
+ +
+ setFilters({ ...filters, world_id: e.target.value })} + placeholder="uuid" + /> + setFilters({ ...filters, stage: e.target.value })} + placeholder="world_builder / iteration / ..." + /> + setFilters({ ...filters, status_filter: e.target.value })} + placeholder="success / error" + /> +
+ +
+
+
+ + + {loading ? ( +
+ {t("common.loading")} +
+ ) : !data || data.items.length === 0 ? ( +

{t("common.no_data")}

+ ) : ( +
+ + + + + + + + + + + + + {data.items.map((log) => ( + + + + + + + + + ))} + +
{t("admin.logs_stage")}{t("admin.logs_status")}{t("admin.logs_latency")}{t("admin.logs_tokens")}{t("admin.logs_created")}
{log.stage} + + {log.status} + + + {log.latency_ms != null ? `${log.latency_ms} ms` : "—"} + + {log.tokens != null ? log.tokens : "—"} + {formatDate(log.created_at)} + +
+
+ )} + {data && data.total > perPage && ( +
+ + {t("common.previous")} {page * perPage - perPage + 1}–{Math.min(page * perPage, data.total)} / {data.total} + +
+ + +
+
+ )} +
+ + setDetailOpen(false)} + title={t("admin.logs_detail")} + size="xl" + footer={ + + } + > + {detailLoading ? ( +
+ {t("common.loading")} +
+ ) : detail ? ( +
+
+ + + + + + +
+ {detail.error && ( +
+
{detail.error}
+
+ )} + {detail.prompt && ( +
+
+                  {detail.prompt}
+                
+
+ )} + {detail.response && ( +
+
+                  {detail.response}
+                
+
+ )} +
+ ) : ( +

{t("common.no_data")}

+ )} +
+
+ ); +} + +function Field({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} diff --git a/frontend/src/components/admin/SettingsPanel.tsx b/frontend/src/components/admin/SettingsPanel.tsx new file mode 100644 index 0000000..268f977 --- /dev/null +++ b/frontend/src/components/admin/SettingsPanel.tsx @@ -0,0 +1,132 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AdminApi } from "@/lib/api"; +import { useToastStore } from "@/stores/toastStore"; +import type { AdminSettingsResponse } from "@/types"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { Input } from "@/components/ui/Input"; +import { Spinner } from "@/components/ui/Spinner"; + +const GROUP_PREFIXES: Array<{ group: string; prefixes: string[]; labelKey: string }> = [ + { group: "llm", prefixes: ["llm_", "llm."], labelKey: "admin.group_llm" }, + { group: "embeddings", prefixes: ["embeddings_", "embeddings."], labelKey: "admin.group_embeddings" }, + { group: "qdrant", prefixes: ["qdrant_", "qdrant."], labelKey: "admin.group_qdrant" }, + { group: "ui", prefixes: ["ui_", "ui.", "site_", "site."], labelKey: "admin.group_ui" }, + { group: "game", prefixes: ["game_", "game."], labelKey: "admin.group_game" }, +]; + +function groupFor(key: string): string { + const lower = key.toLowerCase(); + for (const g of GROUP_PREFIXES) { + if (g.prefixes.some((p) => lower.startsWith(p))) return g.group; + } + return "other"; +} + +export function SettingsPanel() { + const { t } = useTranslation(); + const pushToast = useToastStore((s) => s.push); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [draft, setDraft] = useState>({}); + + useEffect(() => { + setLoading(true); + AdminApi.settings() + .then((res) => { + setData(res); + setDraft({ ...res.settings }); + }) + .catch(() => pushToast("error", t("admin.settings_load_failed"))) + .finally(() => setLoading(false)); + }, [pushToast, t]); + + const grouped = useMemo(() => { + if (!data) return {} as Record>; + const out: Record> = {}; + for (const key of Object.keys(data.settings)) { + const g = groupFor(key); + (out[g] ||= []).push({ key, description: data.descriptions?.[key] }); + } + return out; + }, [data]); + + const handleSave = async () => { + if (!data) return; + setSaving(true); + try { + // Save only changed keys + const diff: Record = {}; + for (const [k, v] of Object.entries(draft)) { + if (data.settings[k] !== v) diff[k] = v; + } + if (Object.keys(diff).length === 0) { + pushToast("info", "No changes to save."); + return; + } + const res = await AdminApi.updateSettings(diff); + setData(res); + setDraft({ ...res.settings }); + pushToast("success", t("admin.settings_saved")); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed"; + pushToast("error", msg); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+ {t("common.loading")} +
+ ); + } + + if (!data) { + return

{t("common.no_data")}

; + } + + const groupOrder = ["llm", "embeddings", "qdrant", "ui", "game", "other"]; + const groupLabelKey: Record = { + llm: "admin.group_llm", + embeddings: "admin.group_embeddings", + qdrant: "admin.group_qdrant", + ui: "admin.group_ui", + game: "admin.group_game", + other: "common.details", + }; + + return ( +
+
+

{t("admin.tab_settings")}

+ +
+ {groupOrder.map((g) => { + const entries = grouped[g]; + if (!entries || entries.length === 0) return null; + return ( + +
+ {entries.map(({ key, description }) => ( + setDraft((d) => ({ ...d, [key]: e.target.value }))} + /> + ))} +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/admin/StatsPanel.tsx b/frontend/src/components/admin/StatsPanel.tsx new file mode 100644 index 0000000..dcba7f7 --- /dev/null +++ b/frontend/src/components/admin/StatsPanel.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AdminApi } from "@/lib/api"; +import { useToastStore } from "@/stores/toastStore"; +import type { AdminStats } from "@/types"; +import { Card } from "@/components/ui/Card"; +import { Spinner } from "@/components/ui/Spinner"; + +export function StatsPanel() { + const { t } = useTranslation(); + const pushToast = useToastStore((s) => s.push); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + AdminApi.stats() + .then(setStats) + .catch((err) => pushToast("error", err instanceof Error ? err.message : "Failed")) + .finally(() => setLoading(false)); + }, [pushToast]); + + if (loading) { + return ( +
+ {t("common.loading")} +
+ ); + } + if (!stats) { + return

{t("common.no_data")}

; + } + + const cards: Array<{ label: string; value: string | number }> = [ + { label: t("admin.stats_users"), value: stats.users }, + { label: t("admin.stats_worlds"), value: stats.worlds }, + { label: t("admin.stats_steps"), value: stats.steps }, + { + label: t("admin.stats_avg_latency"), + value: stats.avg_llm_latency_ms != null ? `${Math.round(stats.avg_llm_latency_ms)} ms` : "—", + }, + ]; + + return ( +
+ {cards.map((c) => ( + +

{c.label}

+

{c.value}

+
+ ))} +
+ ); +} diff --git a/frontend/src/components/admin/TestButtons.tsx b/frontend/src/components/admin/TestButtons.tsx new file mode 100644 index 0000000..e825f17 --- /dev/null +++ b/frontend/src/components/admin/TestButtons.tsx @@ -0,0 +1,301 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AdminApi } from "@/lib/api"; +import { useToastStore } from "@/stores/toastStore"; +import type { + EmbeddingsProbeResult, + EmbeddingsTestResult, + LlmTestResult, + LlmToolsTestResult, + RecreateCollectionsResult, +} from "@/types"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { Input } from "@/components/ui/Input"; +import { Spinner } from "@/components/ui/Spinner"; + +export function TestButtons() { + const { t } = useTranslation(); + return ( +
+ + + + + +
+ ); +} + +function LlmTestCard() { + const { t } = useTranslation(); + const pushToast = useToastStore((s) => s.push); + const [apiUrl, setApiUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [model, setModel] = useState(""); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + const run = async () => { + setLoading(true); + setResult(null); + try { + const r = await AdminApi.testLlm(apiUrl, apiKey, model); + setResult(r); + if (!r.ok) pushToast("error", t("admin.failed")); + else pushToast("success", t("admin.ok")); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed"; + pushToast("error", msg); + } finally { + setLoading(false); + } + }; + + return ( + +
+ setApiUrl(e.target.value)} placeholder="https://api.openai.com/v1/chat/completions" /> + setApiKey(e.target.value)} /> + setModel(e.target.value)} placeholder="gpt-4o-mini" /> +
+
+ + {loading && } +
+ {result && } +
+ ); +} + +function LlmToolsTestCard() { + const { t } = useTranslation(); + const pushToast = useToastStore((s) => s.push); + const [apiUrl, setApiUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [model, setModel] = useState(""); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + const run = async () => { + setLoading(true); + setResult(null); + try { + const r = await AdminApi.testLlmTools({ + api_url: apiUrl, + api_key: apiKey, + model, + }); + setResult(r); + pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : t("admin.failed")); + } catch (err) { + pushToast("error", err instanceof Error ? err.message : "Failed"); + } finally { + setLoading(false); + } + }; + + return ( + +
+ setApiUrl(e.target.value)} /> + setApiKey(e.target.value)} /> + setModel(e.target.value)} /> +
+
+ +
+ {result && } +
+ ); +} + +function EmbeddingsTestCard() { + const { t } = useTranslation(); + const pushToast = useToastStore((s) => s.push); + const [apiUrl, setApiUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [model, setModel] = useState(""); + const [provider, setProvider] = useState(""); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + const run = async () => { + setLoading(true); + setResult(null); + try { + const r = await AdminApi.testEmbeddings({ + api_url: apiUrl, + api_key: apiKey, + model, + provider, + }); + setResult(r); + pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : t("admin.failed")); + } catch (err) { + pushToast("error", err instanceof Error ? err.message : "Failed"); + } finally { + setLoading(false); + } + }; + + return ( + +
+ setApiUrl(e.target.value)} /> + setApiKey(e.target.value)} /> + setModel(e.target.value)} /> + setProvider(e.target.value)} placeholder="openai" /> +
+
+ +
+ {result && } +
+ ); +} + +function ProbeDimensionCard() { + const { t } = useTranslation(); + const pushToast = useToastStore((s) => s.push); + const [apiUrl, setApiUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [model, setModel] = useState(""); + const [provider, setProvider] = useState(""); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + const run = async () => { + setLoading(true); + setResult(null); + try { + const r = await AdminApi.probeDimension({ + api_url: apiUrl, + api_key: apiKey, + model, + provider, + }); + setResult(r); + pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : t("admin.failed")); + } catch (err) { + pushToast("error", err instanceof Error ? err.message : "Failed"); + } finally { + setLoading(false); + } + }; + + return ( + +
+ setApiUrl(e.target.value)} /> + setApiKey(e.target.value)} /> + setModel(e.target.value)} /> + setProvider(e.target.value)} /> +
+
+ +
+ {result && } +
+ ); +} + +function RecreateCollectionsCard() { + const { t } = useTranslation(); + const pushToast = useToastStore((s) => s.push); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + const run = async () => { + setLoading(true); + setResult(null); + try { + const r = await AdminApi.recreateCollections(); + setResult(r); + pushToast("success", `${t("admin.ok")}: dropped=${r.dropped} created=${r.created} dim=${r.dimension}`); + } catch (err) { + pushToast("error", err instanceof Error ? err.message : "Failed"); + } finally { + setLoading(false); + } + }; + + return ( + +

+ Drops and recreates Qdrant collections based on current embeddings dimension. +

+ + {result && ( +
+

+ Dropped: {result.dropped} +

+

+ Created: {result.created} +

+

+ {t("admin.dimension")}: {result.dimension} +

+
+ )} +
+ ); +} + +function TestResultCard({ result }: { result: Record }) { + const { t } = useTranslation(); + const ok = result.ok === true; + return ( +
+

+ {ok ? t("admin.ok") : t("admin.failed")} +

+ {typeof result.elapsed_ms === "number" && ( +

+ {t("admin.elapsed_ms")}: {result.elapsed_ms} +

+ )} + {typeof result.dimension === "number" && ( +

+ {t("admin.dimension")}: {result.dimension} +

+ )} + {typeof result.model === "string" && ( +

+ {t("admin.model")}: {result.model} +

+ )} + {typeof result.response === "string" && ( +
+          {result.response}
+        
+ )} + {typeof result.error === "string" && ( +
+          {result.error}
+        
+ )} + {Array.isArray(result.first_5_values) && ( +

+ first_5_values: [{(result.first_5_values as number[]).slice(0, 5).map((v) => typeof v === "number" ? v.toFixed(4) : String(v)).join(", ")}] +

+ )} + {result.tool_calls != null && ( +
+          {JSON.stringify(result.tool_calls, null, 2)}
+        
+ )} +
+ ); +} diff --git a/frontend/src/components/admin/UsersTable.tsx b/frontend/src/components/admin/UsersTable.tsx new file mode 100644 index 0000000..3dc647f --- /dev/null +++ b/frontend/src/components/admin/UsersTable.tsx @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AdminApi } from "@/lib/api"; +import { useToastStore } from "@/stores/toastStore"; +import type { User } from "@/types"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { Spinner } from "@/components/ui/Spinner"; + +export function UsersTable() { + const { t } = useTranslation(); + const pushToast = useToastStore((s) => s.push); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [updatingId, setUpdatingId] = useState(null); + + const fetchUsers = useCallback(async () => { + setLoading(true); + try { + const res = await AdminApi.users(); + setUsers(res.items); + } catch (err) { + pushToast("error", err instanceof Error ? err.message : "Failed"); + } finally { + setLoading(false); + } + }, [pushToast]); + + useEffect(() => { + void fetchUsers(); + }, [fetchUsers]); + + const update = async (id: string, payload: { is_admin?: boolean; is_active?: boolean }) => { + setUpdatingId(id); + try { + const updated = await AdminApi.updateUser(id, payload); + setUsers((list) => list.map((u) => (u.id === id ? updated : u))); + } catch (err) { + pushToast("error", err instanceof Error ? err.message : "Failed"); + } finally { + setUpdatingId(null); + } + }; + + return ( + + {loading ? ( +
+ {t("common.loading")} +
+ ) : users.length === 0 ? ( +

{t("common.no_data")}

+ ) : ( +
+ + + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + + ))} + +
{t("admin.users_email")}{t("admin.users_username")}{t("admin.users_admin")}{t("admin.users_active")}{t("admin.users_created")}{t("admin.users_last_login")}{t("common.actions")}
{u.email}{u.username} + + {u.is_admin ? t("common.yes") : t("common.no")} + + + + {u.is_active !== false ? t("common.yes") : t("common.no")} + + {formatDate(u.created_at)}{u.last_login_at ? formatDate(u.last_login_at) : "—"} + + +
+
+ )} +
+ ); +} + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..08fb994 --- /dev/null +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,41 @@ +import { useEffect, type ReactNode } from "react"; +import { Navigate, useLocation } from "react-router-dom"; +import { useAuthStore } from "@/stores/authStore"; +import { Spinner } from "@/components/ui/Spinner"; + +export interface ProtectedRouteProps { + children: ReactNode; + requireAdmin?: boolean; +} + +export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) { + const location = useLocation(); + const status = useAuthStore((s) => s.status); + const user = useAuthStore((s) => s.user); + const bootstrap = useAuthStore((s) => s.bootstrap); + const bootstrapped = useAuthStore((s) => s.status !== "idle"); + + useEffect(() => { + if (status === "idle") { + void bootstrap(); + } + }, [status, bootstrap]); + + if (!bootstrapped || status === "loading") { + return ( +
+ Loading… +
+ ); + } + + if (status !== "authenticated" || !user) { + return ; + } + + if (requireAdmin && !user.is_admin) { + return ; + } + + return <>{children}; +} diff --git a/frontend/src/components/sessions/ActionInput.tsx b/frontend/src/components/sessions/ActionInput.tsx new file mode 100644 index 0000000..33b6e4d --- /dev/null +++ b/frontend/src/components/sessions/ActionInput.tsx @@ -0,0 +1,81 @@ +import { useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { Button } from "@/components/ui/Button"; + +export interface ActionInputProps { + onSubmit: (action: string) => void; + submitting: boolean; + suggestedActions: string[]; + onSuggestedClick?: (action: string) => void; + placeholder?: string; + className?: string; + autoFocus?: boolean; +} + +export function ActionInput({ + onSubmit, + submitting, + suggestedActions, + onSuggestedClick, + placeholder, + className, + autoFocus = false, +}: ActionInputProps) { + const { t } = useTranslation(); + const [text, setText] = useState(""); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const value = text.trim(); + if (!value || submitting) return; + onSubmit(value); + setText(""); + }; + + const handleSuggested = (action: string) => { + if (submitting) return; + if (onSuggestedClick) onSuggestedClick(action); + else onSubmit(action); + }; + + return ( +
+ {suggestedActions.length > 0 && ( +
+ {suggestedActions.map((a, i) => ( + + ))} +
+ )} +
+