217 KiB
AI-RPG — Техническое задание для ИИ-агента (TDD)
Версия документа: 1.0.0 Дата: 2026-06-20 Статус: Draft, готов к реализации Аудитория: ИИ-агент-разработчик GLM-5.2), работающий по методологии TDD (Red-Green-Refactor) Логотип:
icon.png(по умолчание идёт в составе архива, иные загружается через UI админ-настроек, см. §6.5 и §12.4)
Содержание
- Глоссарий терминов
- Обзор проекта и цели
- Архитектура системы (high-level)
- Стек технологий
- Детальная схема БД
- API спецификация (OpenAPI)
- SSE-протокол
- Tool-сигнатуры (JSON-schema)
- Потоки (flows) с sequence-диаграммами
- Промпт-шаблоны и контекстный менеджер
- RAG-подсистема и валидация состояния
- Фронтенд-архитектура
- DevOps / Deployment
- TDD-методология и тестовая инфраструктура
- Нефункциональные требования
- Стратегия обработки ошибок
- Roadmap реализации (по спринтам)
- Чек-листы и приёмочные критерии
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. Цели документа
Этот документ — техническое задание для ИИ-агента-разработчика. Он преследует три цели:
- Спроектировать все части архитектуры целиком: детальную схему БД (со всеми колонками, типами, индексами), OpenAPI-спецификацию, JSON-schema всех tool-сигнатур, фронтенд-архитектуру, DevOps-конфигурацию, RAG-подсистему на Qdrant, контекстную оптимизацию и тестовую инфраструктуру. Документ самодостаточен — для реализации не требуется внешний источник.
- Зафиксировать методологию TDD (Red-Green-Refactor) как обязательную для всех изменений: каждая фича начинается с failing test, реализуется минимально, рефакторится безопасно.
- Дать roadmap по спринтам с приоритетами, артефактами и приёмочными критериями, чтобы ИИ-агент мог планировать последовательность работы.
2.3. Ключевые принципы архитектуры
Четыре принципа, зафиксированные в этом ТЗ и обязательные к исполнению:
-
World = Session. Один мир — одна играбельная сессия. Сессия не сбрасывается между заходами игрока. Это означает, что таблица
worldsхранит и "шаблонные" данные (schemas, environment_schema), и "живое" состояние (environment, current_time). Альтернатива с отдельной таблицейsessionsрассматривалась и отвергнута — она усложняет UX (игроку нужно выбирать сессию) и не даёт преимуществ для single-player игры. -
Environment как быстрый контекст. Environment — это JSON-блок, всегда присутствующий в промпте LLM без вызова инструментов. LLM видит
player(полное состояние персонажа),current_location,plot_railsи может дополнительно через toolenv_updateперетаскивать в environment релевантные Entity (например, NPC, с которым игрок сейчас взаимодействует). ИИ управляет тем, что находится в environment — это его "рабочая память". -
Tools-first. Любое изменение состояния мира, любая коммуникация с пользователем происходит через явные tool calls. LLM не пишет «вы получили 10 урона» в свободном тексте — она вызывает
env_updateс патчемplayer.stats.health. Свободный текст LLM остаётся только для нарратива, и даже там он возвращается черезsubmit_step(Phase 2 writer). Это даёт четыре преимущества: детерминизм (изменения логируются), валидация (state_validator проверяет patch), обратная связь (LLM видит ошибки), UX-транспарентность (фронтенд показывает пузырьки tool calls). -
Изоляция контекстов. Каждый поток (world_builder, world_editor, orchestrator, intro_scene) имеет свой system-промпт и свой набор сообщений. Это критично для предотвращения галлюцинаций: orchestrator не должен видеть сообщения world_builder-а, иначе он может "продолжить" редактирование мира во время игры.
2.4. Что НЕ входит в скоуп
- Мультиплеер (несколько игроков в одном мире) — отложен до v2.
- Голосовой ввод/вывод — отложен.
- Интеграция с внешними VTT (Roll20, Foundry) — отложен.
- Мобильные нативные приложения — только веб.
3. Архитектура системы (high-level)
3.1. C4-диаграмма уровня Container
flowchart TB
subgraph "Пользователь"
Player[Игрок]
Admin[Администратор]
end
subgraph "Браузер"
FE[React Frontend<br/>Vite + TS + zustand]
end
subgraph "Сервер приложений"
Nginx[nginx<br/>статика + reverse-proxy]
API[FastAPI Backend<br/>uvicorn]
Engine[Engine Layer<br/>game_master, world_builder,<br/>world_editor, context]
Core[Core Layer<br/>llm_client, rag, validator,<br/>security, settings]
end
subgraph "Внешние сервисы"
LLM[LLM Provider<br/>OpenAI-compatible API]
end
subgraph "Хранилище"
PG[(PostgreSQL 15+<br/>реляционные данные)]
QD[(Qdrant<br/>векторный индекс)]
Vol[(Volume data/<br/>бэкапы, артефакты)]
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. Кратко:
- 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. Принципы взаимодействия
- REST + SSE. Команды от клиента — REST (POST/GET/PUT/DELETE). Долгие операции (orchestrator, world_builder) возвращают SSE-стрим с прогрессом.
- JWT в Authorization header. Все эндпоинты, кроме
/auth/*и/register/*, требуютAuthorization: Bearer <token>. - Idempotency. Все POST-мутации принимают опциональный
Idempotency-Keyheader; повторный запрос с тем же ключом возвращает кешированный результат. - 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/(путь из.envDATA_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-диаграмма
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, но для справки — ключевые таблицы:
-- 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):
users(нет FK)settings(нет FK)world_presets(FK→users)worlds(FK→users, world_presets)entities(FK→worlds)story_entries(FK→worlds)deferred_triggers(FK→worlds)llm_call_logs(FK→users, worlds)steps(FK→worlds, llm_call_logs)step_tool_calls(FK→steps)- Все индексы и constraints
6. API спецификация (OpenAPI)
Базовый префикс всех маршрутов: /api. Все эндпоинты, кроме /api/auth/* и /api/register/*, требуют заголовок Authorization: Bearer <JWT>. Ответы — JSON. Ошибки — в формате {error: {code: string, message: string, details?: object}}.
6.1. Аутентификация и регистрация
POST /api/register
Регистрация нового пользователя. Открывать только если в БД нет ни одного админа — иначе нужен admin-token.
Request body:
{
"email": "user@example.com",
"username": "player1",
"password": "secret123",
"password_confirm": "secret123"
}
Response 201:
{
"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>. Если токен не совпадает с admin.setup_token из settings — 403.
POST /api/auth/login
Request body:
{
"login": "user@example.com", // email ИЛИ username
"password": "secret123"
}
Response 200:
{
"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:
{
"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). Возвращает world_id и SSE-канал.
Request body:
{
"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:
{
"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:
{ "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:
{
"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:
{
"action": "Я открываю дверь мечом",
"action_source": "custom" // "custom" | "suggested"
}
Response 202:
{ "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). Возвращает:
{
"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). Возвращает:
{
"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. Возвращает:
{
"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}. Возвращает:
{
"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 <JWT>
Cache-Control: no-cache
Каждое событие:
event: <event_type>
data: <json_string>
Двойной \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?} |
change_proposed |
{diff: [{path, op, old, new}]} |
comment |
{text} |
apply_changes |
{} |
discard_changes |
{} |
7.6. Reconnect-стратегия
- Фронтенд использует
EventSource(нативный) с автоконнектом. - При reconnect клиент шлёт
Last-Event-IDheader — сервер возобновляет с пропущенных событий. - Если
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-объект с одинаковой структурой:
{
"ok": true,
"data": { ... },
"message": "человекочитаемое описание для LLM"
}
или в случае ошибки:
{
"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
Создаёт новую сущность в мире.
{
"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)"
}
}
}
}
Возвращает:
{ "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).
{
"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
Список сущностей с фильтром.
{
"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.
{
"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().
{
"name": "entity_delete",
"parameters": {
"required": ["entity_id"],
"properties": {
"entity_id": { "type": "string" },
"reason": { "type": "string", "description": "Почему удаляется (для лога)" }
}
}
}
8.2.6. env_update
Ключевой инструмент — мутирует environment через валидируемый patch.
{
"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 } }
}
}
}
}
}
Возвращает:
{
"ok": true,
"data": { "applied_paths": ["player.stats.health", "player.stats.mana"] },
"message": "Environment обновлён"
}
8.2.7. env_get
Возвращает текущее значение поля environment (или весь environment если path не указан).
{
"name": "env_get",
"parameters": {
"properties": {
"path": { "type": "string", "description": "Например 'player.stats' или 'plot_rails.current_goals'" }
}
}
}
8.2.8. update_plot_rails
Специализированный инструмент для управления сюжетными рельсами.
{
"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).
{
"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)." }
}
}
}
Возвращает:
{
"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', фоновый индексатор досчитает вектор позже.
{
"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
Планирует отложенное событие.
{
"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).
{
"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 ошибиться в арифметике.
{
"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) для воспроизводимости.
{
"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).
{
"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.
{
"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 возвращает сцену.
{
"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 следующих действия для игрока.
{
"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.
{
"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.
{
"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
Просто текстовый комментарий в чат (не требует ответа).
{
"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.
{
"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:
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():
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, начальные сущности и вступительную сцену.
Шаги:
- Получение шаблона мира (built-in / опубликованный / form).
- Игрок заполняет: название, язык, своего персонажа, заметки.
- Первоначальная генерация мира (LLM генерирует
schemas,environment_schema,environmentсplayer). - Редактирование (через
world_editorflow). - Догенерация начального состояния (локации, персонажи, предметы, цели).
- Генерация вступительной сцены + 1-3 действий.
- Игрок нажимает "Начать игру" → переход на страницу мира.
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 попыток). Если не вышло — SSEerrorс кодомschema_generation_failed. - LLM не вызвала
submit_planзаmax_substeps→ WB форсит завершение сsubmit_planот последнего состояния. - Игрок закрыл вкладку → мир остаётся в
status=draft, доступен в списке миров с пометкой "Не завершён".
9.2. Поток редактирования мира (world_editor)
Запускается при POST /api/worlds/{id}/edit. Игрок даёт текстовую инструкцию, LLM предлагает изменения, игрок принимает/отклоняет.
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 секунд → SSEwarning"Ожидание ответа". - Игрок вручную правит 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-диаграмма
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). Каждый цикл:
- ORC собирает контекст: system prompt + environment + recent_steps (с учётом summary) + player_action.
- LLM получает контекст и массив доступных tools (game tools +
submit_plan). - LLM может вызвать несколько tools в одном response (parallel tool calls).
- ORC исполняет tools, эмитит SSE
tool_call, добавляетtool_resultв messages. - 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.
- ORC собирает компактный контекст: system prompt (writer) + plan + summary из Phase 1 + environment (snapshot).
- LLM вызывает
submit_step(scene_text, delta_time). - ORC стримит
scene_textво фронтенд через SSEscene_chunk. - После завершения —
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):
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 (если нужно):
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:
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).
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=<ADMIN_SETUP_TOKEN>. Токен берётся из .env (если задан) или генерируется случайный и сохраняется в settings.
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 дней).
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):
# 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
# 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):
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-индексы для быстрых фильтров:
# 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
# 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 маленьким, а полный текст — в реляционной БД.
# 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
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):
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 проходит через него.
Сигнатуры:
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:
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:
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 токенов)
Если бюджет превышен, контекстный менеджер поочерёдно применяет деградацию:
- Уменьшает
recent— отбрасывает самые старые из guaranteed, но не ниже 4 последних шагей. - Уменьшает
rag— отбрасывает самые низко-скоринговые факты. - Урезает
summaryдо 200 токенов (берёт первое предложение + ключевые имена). - Если всё ещё превышено — форсирует генерацию нового summary для большего диапазона шагов и повторяет цикл.
Если после всех шагов промпт всё ещё не помещается — возвращается ошибка context_overflow, итерация помечается failed, оператору показывается warning «пора увеличить context window модели или уменьшить guaranteed_messages».
11.5.2. Трёхуровневая стратегия контекста
Контекст строится из трёх уровней, каждый со своей политикой устаревания:
-
Environment (всегда в контексте). JSON-блок
player+current_location+plot_rails+ кастомные поля. Не требует tool call — LLM видит его сразу в system-промпте. Это «рабочая память» GM. Оптимальный размер — 1500–2500 токенов; если превышает, контекстный менеджер логирует warning и предлагает оператору упроститьenvironment_schema. -
Recent messages (guaranteed). Последние N шагов (default 10, настраивается в
settings.context.guaranteed_messages). Каждый шаг =user: action+assistant: scene_text. Если шаги длинные, контекстный менеджер обрезаетscene_textдо 500 токенов, сохраняя начало (200) и конец (300) — так сохраняются и вступление сцены, и финальное действие. -
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_querywith 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 == <current_world> обязателен для каждого 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. Оценка токенов
# 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.
# 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:
- UI вызывает
POST /api/admin/test/embeddings/probe-dimensionс телом{"text": "hello world"}. - Backend дёргает
OpenAIEmbedder.embed(["hello world"])с текущими настройками. - Возвращает
{"ok": true, "dimension": 1536, "model": "text-embedding-3-small", "elapsed_ms": 142}. - 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.
# 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
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
login: (creds: LoginRequest) => Promise<void>;
logout: () => void;
refresh: () => Promise<void>;
fetchMe: () => Promise<void>;
}
Хранит токены в localStorage. При истечении access_token — автоматически вызывает refresh().
worldsStore
interface WorldsState {
worlds: WorldSummary[];
currentWorld: World | null;
isLoading: boolean;
error: string | null;
fetchWorlds: () => Promise<void>;
fetchWorld: (id: string) => Promise<void>;
createWorld: (req: CreateWorldRequest) => Promise<string>;
deleteWorld: (id: string) => Promise<void>;
}
sessionStore
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<void>;
retry: () => Promise<void>;
rollback: () => Promise<void>;
answerClarification: (text: string) => Promise<void>;
}
uiStore
interface TestResult {
kind: 'llm' | 'llm_tools' | 'embeddings' | 'probe_dimension';
ok: boolean;
warning?: boolean;
data: Record<string, unknown>; // 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:
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-требования
-
Прогресс LLM-шагов. Во время orchestrator показывает прогресс-бар с тремя фазами. Текущая фаза подсвечена, completed — зелёная, pending — серая.
-
Пузырьки tool calls. Каждый вызванный инструмент отображается как сворачиваемый пузырь в чате:
{tool: 'env_update', summary: 'player.stats.health -10', is_success: true}. По клику разворачивается полныйargumentsиresult. -
Streaming текста. Phase 2
scene_textстримится посимвольно (или по словам для оптимизации). Текст появляется "печатной машинкой". -
Кнопка "Повторить генерацию". Показывается если:
- SSE закрылся с
errorсобытием. - Таймаут 60 секунд без событий.
- Пользователь вручную нажал "Отменить" → step помечается
failed, доступен retry.
- SSE закрылся с
-
Clarification modal. Когда world_editor вызывает
ask_user, появляется модальное окно с вопросом и опциональными вариантами. Поле ответа — textarea, кнопка "Отправить". -
Rollback. Кнопка "Откатить последний ход" в меню шага. Подтверждение через modal.
-
i18n. Все UI-строки — через
react-i18nextt('key'). Язык переключается в шапке, сохраняется вlocalStorage. При смене языка — пере-рендер без перезагрузки страницы. -
Тёмная тема. Tailwind
dark:классы. Сохраняется вuiStore.theme. -
Responsive. Mobile-first. На экранах <768px чат занимает всю ширину, environment panel сворачивается в drawer.
-
Страница настроек админки — диагностические кнопки. На
/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 результатов).
- «Тест LLM» (
-
Загрузка иконки через 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 проверяется на бэке.
- favicon (PNG 32×32 / 64×64, до 100KB) — обновляет
-
Превью результата тестов.
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 — плоские ключи:
{
"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:
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
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
# 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:
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 — полный список переменных
# === 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:
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).
#!/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):
# 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:
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 цикл
Каждое изменение в коде начинается с теста. Это обязательное правило для ИИ-агента-разработчика. Цикл:
- Red: Напиши тест, который описывает желаемое поведение. Запусти — он падает (потому что реализации нет или она неверна).
- Green: Напиши минимальную реализацию, чтобы тест прошёл. Не больше, не меньше.
- 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 — ключевые фикстуры
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 мок
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 тест:
# 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 — минимальная реализация:
# 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
# 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 (полный сценарий: логин → создание мира → итерация).
// 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-вызов с
traceparentpropagation. - 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 план:
- Поднять новый сервер с тем же docker-compose.
- Восстановить БД из последнего дампа:
gunzip -c backup.sql.gz | psql. - Применить pending migrations:
alembic upgrade head. - Smoke-test:
GET /api/health→ 200. - Переключить 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)
16.4. Логирование ошибок
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-циклы:
- Test:
alembic upgrade headсоздаёт все таблицы → Impl: миграция 001. (pgvector НЕ нужен.) - Test:
seed.pyзаполняетsettings→ Impl: seed script (включая дефолтныеqdrant.*,embeddings.*,context.*ключи). - Test:
GET /api/healthвозвращает{status:ok, db:true, qdrant:true, llm:false}→ Impl: health endpoint с проверкой Qdrant. - 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-циклы:
- Test:
POST /api/register/adminс верным токеном создаёт админа → Impl. - Test:
POST /api/auth/loginпо email возвращает JWT → Impl. - Test:
POST /api/auth/loginпо username возвращает JWT → Impl. - Test: защищённый endpoint без token → 401.
- Test:
GET /api/admin/settingsбез admin → 403. - Test:
PATCH /api/admin/settingsобновляет значение → Impl.
Артефакты:
/login,/register,/register/adminстраницы./adminстраница с формой настроек.- Admin setup URL выводится в лог при старте.
Sprint 3: World Builder
Цель: Создание мира из пресета или формы, генерация схем и environment.
TDD-циклы:
- Test:
POST /api/worldsсmode=formсоздаёт World соstatus=draft→ Impl. - Test: WorldBuilder генерирует
schemasчерез mock LLM → Impl. - Test: SSE
world_schema_generatedэмитится → Impl. - Test:
state_validator.validate_worldпринимает сгенерированный мир → Impl. - Test: WorldBuilder dogenerates entities → Impl.
Артефакты:
/worlds/newстраница с выбором пресета/формы.- SSE-стрим
world_builderработает end-to-end. - Минимум 1 replay-фикстура LLM-ответов для тестов.
Sprint 4: World Editor
Цель: Редактирование мира через чат + ручные правки JSON.
TDD-циклы:
- Test:
POST /api/worlds/{id}/editзапускает world_editor stream → Impl. - Test:
ask_usertool блокирует поток до ответа → Impl. - Test:
propose_changesвозвращает diff → Impl. - Test:
POST .../applyкоммитит staging → Impl. - Test:
POST .../discardоткатывает staging → Impl. - 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-циклы:
- Test: Phase 1 вызывает LLM, LLM вызывает
env_update, завершаетсяsubmit_plan→ Impl. - Test: Phase 2 LLM вызывает
submit_step, scene_text стримится → Impl. - Test: Phase 3 persist коммитит state, обновляет
current_time→ Impl. - Test:
POST /api/sessions/.../iterateвозвращает SSE URL → Impl. - Test:
POST /api/sessions/.../retryповторяет последний шаг → Impl. - Test:
POST /api/sessions/.../rollbackоткатывает шаг → Impl.
Артефакты:
/worlds/:id/playстраница с чатом.- ToolCallBubble компонент.
- Прогресс-бар фаз.
- Кнопки "Повторить", "Отменить", "Откатить".
Sprint 6: Фронтенд-polish + i18n
Цель: Полный UI с локализацией, тёмной темой, responsive.
TDD-циклы:
- Test: переключение языка re-renderит UI без перезагрузки → Impl.
- Test: тёмная тема применяется через Tailwind
dark:→ Impl. - Test: mobile layout <768px сворачивает sidebar → Impl.
- 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-циклы:
- Test:
rag_queryчерез Qdrant возвращает релевантные StoryEntry (с mock embeddings) → Impl (§11.1.4). - Test:
rag_addсоздаёт StoryEntry + upsert-ит точку в Qdrantstory_entries→ Impl (§11.1.5). - Test: фоновый
embedding_indexerподхватываетembedding_status='pending'и индексирует → Impl (§11.6.4). - Test:
build_embedder()fallback-правило: еслиembeddings.provider='openai'иapi_url/api_keyпустые → берутсяllm.*(§11.6.2) → Impl. - Test:
POST /api/admin/test/embeddings/probe-dimensionвозвращает корректную dimension для mock-провайдера → Impl. - Test:
schedule_triggerсоздаёт DeferredTrigger → Impl. - Test: Phase 3.1 запускает subagent для pending triggers → Impl.
- Test: Phase 3.2 генерирует summary при превышении
context.compression_threshold_messages→ Impl. - Test: Phase 3.3 вызывает
suggest_actions→ Impl. - Test: контекстный менеджер
build_context()возвращает messages, умещающиеся в бюджет токенов (с деградацией: recent → rag → summary) → Impl (§11.5). - Test: изоляция миров —
rag_query(world_id=A)не возвращает результаты мира B (через payload-фильтр Qdrant) → Impl. - 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_subagenttool работает.- Summary в
story_entriesсentry_type='summary'. - Admin-панель показывает логи LLM с фильтрами + диагностические кнопки на странице настроек (см. §12.4).
Sprint 8: Polish, Performance, Production-readiness
Цель: Production-ready deploy, метрики, бэкапы, документация.
TDD-циклы:
- Test: Prometheus
/metricsendpoint отдаёт метрики → Impl. - Test: backup script создаёт gzip dump → Impl.
- Test: rate limiter возвращает 429 при превышении → Impl.
- Test: load test (50 concurrent users) проходит без ошибок → Impl (Locust script).
- 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.
Приоритеты и зависимости
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).
- Если добавлена новая настройка — она есть в
settingsseed и в.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 сделан перед деплоем.
.envproduction обновлён (если новые переменные).- 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 записи
---
Task ID: <task id, e.g. S5-3>
Agent: <agent name/ID>
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 |