# AI-RPG — Техническое задание для ИИ-агента (TDD)
> **Версия документа:** 1.0.0
> **Дата:** 2026-06-20
> **Статус:** Draft, готов к реализации
> **Аудитория:** ИИ-агент-разработчик GLM-5.2), работающий по методологии TDD (Red-Green-Refactor)
> **Логотип:** `icon.png` (по умолчание идёт в составе архива, иные загружается через UI админ-настроек, см. §6.5 и §12.4)
---
## Содержание
1. [Глоссарий терминов](#1-глоссарий-терминов)
2. [Обзор проекта и цели](#2-обзор-проекта-и-цели)
3. [Архитектура системы (high-level)](#3-архитектура-системы-high-level)
4. [Стек технологий](#4-стек-технологий)
5. [Детальная схема БД](#5-детальная-схема-бд)
6. [API спецификация (OpenAPI)](#6-api-спецификация-openapi)
7. [SSE-протокол](#7-sse-протокол)
8. [Tool-сигнатуры (JSON-schema)](#8-tool-сигнатуры-json-schema)
9. [Потоки (flows) с sequence-диаграммами](#9-потоки-flows-с-sequence-диаграммами)
10. [Промпт-шаблоны и контекстный менеджер](#10-промпт-шаблоны-и-контекстный-менеджер)
11. [RAG-подсистема и валидация состояния](#11-rag-подсистема-и-валидация-состояния)
12. [Фронтенд-архитектура](#12-фронтенд-архитектура)
13. [DevOps / Deployment](#13-devops--deployment)
14. [TDD-методология и тестовая инфраструктура](#14-tdd-методология-и-тестовая-инфраструктура)
15. [Нефункциональные требования](#15-нефункциональные-требования)
16. [Стратегия обработки ошибок](#16-стратегия-обработки-ошибок)
17. [Roadmap реализации (по спринтам)](#17-roadmap-реализации-по-спринтам)
18. [Чек-листы и приёмочные критерии](#18-чек-листы-и-приёмочные-критерии)
---
## 1. Глоссарий терминов
В этом разделе зафиксированы машинно-читаемые определения всех ключевых терминов. ИИ-агент обязан использовать термины строго в соответствии с этими определениями; если в исходном коде или промптах встречается термин не из глоссария, его нужно добавить сюда через PR.
| Термин | Определение |
|---|---|
| **AI-RPG** | Текстовая ролевая игра с ИИ-мастером (GM), построенная на FastAPI + PostgreSQL + React. |
| **GM (Game Master)** | Роль LLM, отвечающая за генерацию сценария, управление состоянием мира и нарратив. Не путать с пользователем-администратором. |
| **World (Мир)** | Совокупность схем сущностей, правил, окружения и текущего состояния. Один World = одна играбельная сессия. Хранится в таблице `worlds`. |
| **WorldPreset (Пресет мира)** | Шаблон мира, из которого можно создать новый World. Хранится в `world_presets`. Поля `status: draft \| ready \| archived`. |
| **Session (Сессия)** | Активное состояние игрока в конкретном World. В текущей архитектуре Session ≡ World: один мир — одна сессия, не сбрасывается между заходами. |
| **Environment (Окружение)** | JSON-блок, всегда присутствующий в промпте LLM. Содержит `player`, `current_location`, `plot_rails` и кастомные поля. Управляется через tool `env_update`. |
| **Plot Rails (Сюжетные рельсы)** | Подструктура environment: `{hooks: list[str], current_goals: list[str], completed_goals?: list[str]}`. Управляется через `update_plot_rails`. |
| **Entity (Сущность)** | Экземпляр типа из `world.schemas` (character, item, location, faction, ...). Хранится в `entities`. Привязан к миру. |
| **Step (Шаг)** | Единица итерации сессии: action игрока → response LLM. Хранится в `steps`. Содержит action, scene_text, tool_calls, metadata. |
| **Tool Call** | Вызов инструмента LLM в формате OpenAI function-calling. Любое изменение состояния мира происходит только через tool call. |
| **Phase (Фаза)** | Этап итерации orchestrator: Phase 1 (planner+executor), Phase 2 (writer), Phase 3 (persist+triggers+summary+suggestions). |
| **DeferredTrigger (Отложенный триггер)** | Событие, привязанное к игровому времени, активируемое когда `fire_at <= world.current_time`. Хранится в `deferred_triggers`. |
| **StoryEntry (Сюжетная запись)** | RAG-индексированный факт, не привязанный к сущности. Сам вектор хранится в Qdrant (collection `story_entries`), в PostgreSQL — только текст и метаданные. |
| **Summary (Сжатие контекста)** | LLM-сгенерированная выжимка N сообщений истории, заменяющая их в контексте когда история превышает порог сжатия. |
| **RAG (Retrieval-Augmented Generation)** | Подсистема семантического поиска по `story_entries` и `entities` через внешний векторный индекс **Qdrant**. Используется через tools `rag_query` / `rag_add`. PostgreSQL хранит только текст и метаданные; векторы живут в Qdrant-коллекциях. |
| **SSE (Server-Sent Events)** | Протокол стриминга прогресса LLM-итераций во фронтенд. Реализован через `sse-starlette`. |
| **Orchestrator** | Главный движок итерации сессии, координирующий три фазы. Реализован в `app/engine/game_master.py`. |
| **World Builder** | Поток первоначального создания мира из шаблона/формы. Реализован в `app/engine/world_builder.py`. |
| **World Editor** | Поток редактирования существующего мира через чат + ручные правки JSON. Реализован в `app/engine/world_editor.py`. |
| **Intro Scene** | Поток генерации вступительной сцены с первыми 1-3 действиями. Запускается после `world_builder`. |
| **TDD (Test-Driven Development)** | Методология разработки Red-Green-Refactor: сначала failing test, потом реализация, потом рефакторинг. Обязательна для всех изменений. |
| **Red / Green / Refactor** | Три фазы TDD-цикла. Red — пишем тест, который падает. Green — минимальная реализация, чтобы тест прошёл. Refactor — улучшаем код, не ломая тесты. |
| **Tool Result** | JSON-ответ инструмента, возвращаемый LLM как `tool_result` сообщение. Содержит либо `ok: true` + data, либо `ok: false` + error. |
| **State Patch** | JSON-патч для применения изменений к environment. Формат: `{field_path: new_value}` или `{field_path: {op: "inc", by: N}}`. |
| **Subagent** | Вложенный LLM-вызов для офэкранных действий (Phase 3.1). Реализован через tool `run_subagent`. |
| **LlmCallLog** | Запись о каждом вызове LLM: prompt, response, tokens, latency, error. Хранится в `llm_call_logs` в отдельной транзакции. |
---
## 2. Обзор проекта и цели
### 2.1. Что строим
AI-RPG — это веб-приложение, в котором игрок ведёт текстовую ролевую игру с ИИ-мастером (GM). Игрок создаёт мир (или выбирает готовый пресет), настраивает персонажа, и далее вступает в пошаговое взаимодействие: каждое действие игрока обрабатывается трёхфазным orchestrator-ом, который генерирует нарратив, обновляет состояние мира через tool calls, и предлагает 1-3 следующих действия.
Система спроектирована под небольшие локальные LLM (7B-параметров): все промпты оптимизированы под контекст 8K-32K токенов, tool calls используются для детерминированных изменений состояния (LLM не пишет свободный текст вида «вы получили 10 урона» — она вызывает `env_update` с патчем `player.stats.health`).
### 2.2. Цели документа
Этот документ — **техническое задание для ИИ-агента-разработчика**. Он преследует три цели:
1. **Спроектировать все части архитектуры** целиком: детальную схему БД (со всеми колонками, типами, индексами), OpenAPI-спецификацию, JSON-schema всех tool-сигнатур, фронтенд-архитектуру, DevOps-конфигурацию, RAG-подсистему на Qdrant, контекстную оптимизацию и тестовую инфраструктуру. Документ самодостаточен — для реализации не требуется внешний источник.
2. **Зафиксировать методологию TDD (Red-Green-Refactor)** как обязательную для всех изменений: каждая фича начинается с failing test, реализуется минимально, рефакторится безопасно.
3. **Дать roadmap по спринтам** с приоритетами, артефактами и приёмочными критериями, чтобы ИИ-агент мог планировать последовательность работы.
### 2.3. Ключевые принципы архитектуры
Четыре принципа, зафиксированные в этом ТЗ и обязательные к исполнению:
1. **World = Session.** Один мир — одна играбельная сессия. Сессия не сбрасывается между заходами игрока. Это означает, что таблица `worlds` хранит и "шаблонные" данные (schemas, environment_schema), и "живое" состояние (environment, current_time). Альтернатива с отдельной таблицей `sessions` рассматривалась и отвергнута — она усложняет UX (игроку нужно выбирать сессию) и не даёт преимуществ для single-player игры.
2. **Environment как быстрый контекст.** Environment — это JSON-блок, всегда присутствующий в промпте LLM без вызова инструментов. LLM видит `player` (полное состояние персонажа), `current_location`, `plot_rails` и может дополнительно через tool `env_update` перетаскивать в environment релевантные Entity (например, NPC, с которым игрок сейчас взаимодействует). ИИ управляет тем, что находится в environment — это его "рабочая память".
3. **Tools-first.** Любое изменение состояния мира, любая коммуникация с пользователем происходит через явные tool calls. LLM не пишет «вы получили 10 урона» в свободном тексте — она вызывает `env_update` с патчем `player.stats.health`. Свободный текст LLM остаётся только для нарратива, и даже там он возвращается через `submit_step` (Phase 2 writer). Это даёт четыре преимущества: детерминизм (изменения логируются), валидация (state_validator проверяет patch), обратная связь (LLM видит ошибки), UX-транспарентность (фронтенд показывает пузырьки tool calls).
4. **Изоляция контекстов.** Каждый поток (world_builder, world_editor, orchestrator, intro_scene) имеет свой system-промпт и свой набор сообщений. Это критично для предотвращения галлюцинаций: orchestrator не должен видеть сообщения world_builder-а, иначе он может "продолжить" редактирование мира во время игры.
### 2.4. Что НЕ входит в скоуп
- Мультиплеер (несколько игроков в одном мире) — отложен до v2.
- Голосовой ввод/вывод — отложен.
- Интеграция с внешними VTT (Roll20, Foundry) — отложен.
- Мобильные нативные приложения — только веб.
---
## 3. Архитектура системы (high-level)
### 3.1. C4-диаграмма уровня Container
```mermaid
flowchart TB
subgraph "Пользователь"
Player[Игрок]
Admin[Администратор]
end
subgraph "Браузер"
FE[React Frontend
Vite + TS + zustand]
end
subgraph "Сервер приложений"
Nginx[nginx
статика + reverse-proxy]
API[FastAPI Backend
uvicorn]
Engine[Engine Layer
game_master, world_builder,
world_editor, context]
Core[Core Layer
llm_client, rag, validator,
security, settings]
end
subgraph "Внешние сервисы"
LLM[LLM Provider
OpenAI-compatible API]
end
subgraph "Хранилище"
PG[(PostgreSQL 15+
реляционные данные)]
QD[(Qdrant
векторный индекс)]
Vol[(Volume data/
бэкапы, артефакты)]
end
Player --> FE
Admin --> FE
FE -->|HTTP/SSE| Nginx
Nginx -->|/api/*| API
API --> Engine
Engine --> Core
Core -->|httpx| LLM
Core -->|embeddings API| LLM
Engine -->|SQLAlchemy async| PG
Core -->|SQLAlchemy async| PG
Core -->|qdrant-client| QD
API -->|логи| Vol
style FE fill:#e1f5ff
style API fill:#fff3e0
style PG fill:#e8f5e9
style QD fill:#ede7f6
style LLM fill:#fce4ec
```
### 3.2. Слои бэкенда
Бэкенд разбит на шесть слоёв (директории внутри `app/`). Каждый слой имеет одну ответственность и не должен вызывать слои "через голову" (например, `api/` не должен напрямую дёргать `core/`, минуя `engine/`).
| Слой | Директория | Ответственность | Зависит от |
|---|---|---|---|
| **API** | `app/api/` | FastAPI-роутеры: `auth`, `admin`, `worlds`, `sessions`, `presets`, `misc`. Только HTTP-логика, валидация Pydantic-схемами, вызов `engine/core`. | engine, core, schemas |
| **Engine** | `app/engine/` | Движок игры: `game_master` (основная итерация), `world_builder`, `world_editor`, `context` (построение промптов), `tools/` (инструменты). | core, models, prompts |
| **Core** | `app/core/` | Сквозные сервисы: `llm` (LLM-клиент), `settings_service`, `rag`, `state_validator`, `security` (JWT). | models |
| **Models** | `app/models/` | SQLAlchemy-модели всех таблиц. | — |
| **Prompts** | `app/prompts/` | Все системные промпты в виде Python-строк с `str.format()` интерполяцией. `get_prompt(stage, language)` — единственная точка доступа. | — |
| **Schemas** | `app/schemas/` | Pydantic-схемы для request/response API. **Не путать с JSON-schema мира** — это разные сущности. | — |
**Правило циклических зависимостей:** слои `api → engine → core → models` образуют однонаправленный граф. `prompts` и `schemas` — листья, их может импортировать кто угодно, они никого не импортируют.
### 3.3. Фронтенд-архитектура (overview)
Подробно — в [разделе 12](#12-фронтенд-архитектура). Кратко:
- **React 18 + TypeScript + Vite.**
- **Состояние:** zustand с доменными сторами: `authStore`, `uiStore`, `worldsStore`, `sessionStore`.
- **Локализация:** react-i18next, два языка (en, ru), расширяемо.
- **Стили:** Tailwind CSS.
- **UI-компоненты:** собственная минимальная библиотека в `src/components/ui/` (Button, Card, Input, Modal, Navbar) + shadcn-стиль `cn`-утилита.
- **Маршруты:** React Router v6. Страницы: `/login`, `/register`, `/worlds`, `/worlds/new`, `/worlds/:id/edit`, `/worlds/:id/play`, `/admin`.
### 3.4. Принципы взаимодействия
1. **REST + SSE.** Команды от клиента — REST (POST/GET/PUT/DELETE). Долгие операции (orchestrator, world_builder) возвращают SSE-стрим с прогрессом.
2. **JWT в Authorization header.** Все эндпоинты, кроме `/auth/*` и `/register/*`, требуют `Authorization: Bearer `.
3. **Idempotency.** Все POST-мутации принимают опциональный `Idempotency-Key` header; повторный запрос с тем же ключом возвращает кешированный результат.
4. **Soft delete.** World и Entity не удаляются физически, а помечаются `status='archived'` или `deleted_at`. Это позволяет откатывать итерации.
---
## 4. Стек технологий
Стек зафиксирован и не подлежит замене без явного ADR (Architecture Decision Record). Любое предложение сменить библиотеку должно быть оформлено как ADR в `docs/adr/`.
### 4.1. Бэкенд
| Компонент | Технология | Версия | Назначение |
|---|---|---|---|
| Язык | Python | 3.12+ | Основной язык бэкенда |
| Web-фреймворк | FastAPI | 0.110+ | HTTP-API, SSE, валидация Pydantic |
| ORM | SQLAlchemy | 2.x (async) | Работа с PostgreSQL |
| БД | PostgreSQL | 15+ | Основное реляционное хранилище (миры, сущности, шаги, логи) |
| Векторное хранилище | Qdrant | 1.8+ | Векторный индекс для RAG (семантический поиск по `story_entries` и `entities`). См. §11. |
| Qdrant client | qdrant-client | 1.8+ | Async-клиент к Qdrant (gRPC/HTTP) |
| HTTP-сервер | uvicorn | 0.27+ | ASGI-сервер |
| Reverse-proxy | nginx | 1.24+ | Раздача статики, проксирование API |
| SSE | sse-starlette | 1.6+ | Стриминг итераций |
| Auth | python-jose | 3.3+ | JWT |
| Password hashing | passlib[bcrypt] | 1.7+ | Хеширование паролей |
| HTTP-клиент | httpx | 0.27+ | LLM-вызовы |
| Migration | Alembic | 1.13+ | Схемные миграции |
| Testing | pytest + pytest-asyncio | 8.x | Unit/integration тесты |
### 4.2. LLM-интеграция
Собственный лёгкий клиент `app/core/llm.py` поверх `httpx`. Поддерживает:
- Обычный режим `chat/completions`.
- Streaming `chat/completions` с `stream=true`.
- `tool_calls` в OpenAI-формате (`tools`, `tool_choice`).
- Retry с экспоненциальной задержкой (`1s, 2s, 4s, max 3 attempts`).
- Логирование каждого вызова в `llm_call_logs` **в отдельной транзакции** (метод `LlmClient._write_log_safely`), чтобы лог выживал даже при откате основной транзакции.
> **Архитектурное решение:** лог пишется в отдельной транзакции через `async with session.begin_nested()` + commit в конце. Если основная транзакция упала, лог остаётся. Это критично для отладки: оператор видит, какой именно запрос был отправлен и что вернулось, даже если итерация упала.
### 4.3. Фронтенд
| Компонент | Технология | Версия | Назначение |
|---|---|---|---|
| Язык | TypeScript | 5.x | Типизация |
| UI-фреймворк | React | 18.x | Компоненты |
| Сборщик | Vite | 5.x | Dev-сервер, бандлинг |
| State | zustand | 4.x | Глобальный стор |
| Локализация | react-i18next | 14.x | en, ru |
| Роутинг | react-router-dom | 6.x | SPA-маршруты |
| Стили | Tailwind CSS | 3.x | Utility-first CSS |
| HTTP | fetch + EventSource | native | REST + SSE |
| Testing | vitest + @testing-library/react | 1.x | Unit/component тесты |
| E2E | Playwright | 1.x | E2E тесты |
### 4.4. Инфраструктура
- **docker-compose** с четырьмя сервисами: `db` (PostgreSQL), `qdrant` (векторное хранилище), `backend`, `frontend`.
- **Volume** `data/` (путь из `.env` `DATA_DIR`) для бэкапов БД и артефактов.
- **Инициализация БД** — `app/migrations/init_db.py` создаёт все таблицы и заполняет дефолтные настройки (LLM, контекст) и встроенные пресеты.
- **Health-check** на `/api/health` возвращает `{status: "ok", db: true/false, llm: true/false}`.
---
## 5. Детальная схема БД
В этом разделе спроектированы **все** таблицы приложения. Схема нормализована, без дублирования; векторные данные (эмбеддинги) в PostgreSQL **не хранятся** — для RAG используется отдельный сервис Qdrant (см. §11).
### 5.1. ER-диаграмма
```mermaid
erDiagram
users ||--o{ world_presets : owns
users ||--o{ worlds : owns
users ||--o{ llm_call_logs : triggers
world_presets ||--o{ worlds : "instantiated from"
worlds ||--o{ entities : contains
worlds ||--o{ steps : "has iterations"
worlds ||--o{ deferred_triggers : "schedules"
worlds ||--o{ story_entries : "indexes facts"
steps ||--o{ llm_call_logs : "produces"
steps ||--o{ step_tool_calls : "executes"
settings {
uuid id PK
string key UK
jsonb value
timestamp updated_at
}
users {
uuid id PK
string email UK
string username UK
string password_hash
boolean is_admin
boolean is_active
timestamp created_at
timestamp last_login_at
}
world_presets {
uuid id PK
uuid owner_id FK
string name
text description
string language
jsonb rules
jsonb time_schema
jsonb schemas
jsonb environment_schema
jsonb environment_initial
string status
boolean is_public
timestamp created_at
timestamp updated_at
}
worlds {
uuid id PK
uuid owner_id FK
uuid preset_id FK
string name
text description
string language
jsonb rules
jsonb time_schema
jsonb schemas
jsonb environment_schema
jsonb environment
jsonb plot_rails
string current_time
string status
timestamp created_at
timestamp updated_at
timestamp last_played_at
}
entities {
uuid id PK
uuid world_id FK
string entity_type
string name
jsonb data
boolean is_in_environment
string qdrant_point_id
timestamp created_at
timestamp updated_at
timestamp deleted_at
}
steps {
uuid id PK
uuid world_id FK
int sequence_number
string player_action
text scene_text
jsonb suggested_actions
jsonb tool_calls_summary
jsonb metadata
uuid phase1_log_id FK
uuid phase2_log_id FK
timestamp created_at
timestamp deleted_at
}
deferred_triggers {
uuid id PK
uuid world_id FK
string fire_at
string event_type
jsonb payload
boolean is_fired
timestamp created_at
timestamp fired_at
}
story_entries {
uuid id PK
uuid world_id FK
text content
string entry_type
string qdrant_point_id
jsonb metadata
timestamp created_at
}
llm_call_logs {
uuid id PK
uuid user_id FK
uuid world_id FK
uuid step_id FK
string stage
string model
jsonb request_messages
jsonb response_message
jsonb tool_calls
int prompt_tokens
int completion_tokens
int latency_ms
string status
text error_message
timestamp created_at
}
step_tool_calls {
uuid id PK
uuid step_id FK
string tool_name
jsonb arguments
jsonb result
boolean is_success
timestamp executed_at
}
```
### 5.2. Описание таблиц
#### 5.2.1. `users`
| Колонка | Тип | Ограничения | Назначение |
|---|---|---|---|
| `id` | UUID | PK, default `gen_random_uuid()` | Первичный ключ |
| `email` | VARCHAR(255) | UNIQUE, NOT NULL | Email пользователя |
| `username` | VARCHAR(64) | UNIQUE, NOT NULL | Логин |
| `password_hash` | VARCHAR(255) | NOT NULL | bcrypt hash |
| `is_admin` | BOOLEAN | NOT NULL, default `false` | Флаг администратора |
| `is_active` | BOOLEAN | NOT NULL, default `true` | Активна ли учётка |
| `created_at` | TIMESTAMPTZ | NOT NULL, default `now()` | Дата создания |
| `last_login_at` | TIMESTAMPTZ | nullable | Последний вход |
**Индексы:** `idx_users_email` (UNIQUE), `idx_users_username` (UNIQUE).
#### 5.2.2. `settings`
Одна таблица для всех админ-настроек (key-value с JSONB value). Это позволяет добавлять новые настройки без миграций схемы.
| Колонка | Тип | Ограничения | Назначение |
|---|---|---|---|
| `id` | UUID | PK | — |
| `key` | VARCHAR(128) | UNIQUE, NOT NULL | Ключ настройки (например `llm.api_url`) |
| `value` | JSONB | NOT NULL | Значение (строка, число, объект, массив) |
| `description` | TEXT | nullable | Человекочитаемое описание |
| `updated_at` | TIMESTAMPTZ | NOT NULL, default `now()` | Последнее изменение |
**Ключи настроек (seed-данные):**
| Ключ | Тип value | Назначение |
|---|---|---|
| `llm.api_url` | string | URL OpenAI-compatible endpoint |
| `llm.api_key` | string | API-ключ (зашифрован на уровне приложения) |
| `llm.model` | string | Имя модели (например `qwen2.5-7b-instruct`) |
| `llm.temperature_orchestrator` | number | Phase 1, default 0.7 |
| `llm.temperature_writer` | number | Phase 2, default 0.85 |
| `llm.max_tokens` | integer | Лимит completion |
| `llm.timeout_seconds` | integer | Timeout на вызов, default 60 |
| `embeddings.provider` | string | `offline_hash` \| `openai`. Если `openai` и `embeddings.api_url`/`api_key` пустые — fallback на `llm.api_url`/`api_key` (см. §11.6.2). |
| `embeddings.api_url` | string | URL OpenAI-compatible embeddings endpoint. Если пусто — fallback на `llm.api_url`. |
| `embeddings.api_key` | string | API-ключ embeddings. Если пусто — fallback на `llm.api_key`. |
| `embeddings.model` | string | Имя модели эмбеддингов (default `text-embedding-3-small`) |
| `embeddings.dimension` | integer | Размерность вектора, default 1536. **Кнопка «Авто-проба»** в UI определяет автоматически (§11.6.3). |
| `embeddings.timeout_seconds` | integer | Timeout embeddings API, default 30 |
| `embeddings.batch_size` | integer | Размер батча для embedding API, default 32 |
| `embeddings.cache_ttl_seconds` | integer | TTL LRU-кеша для query embeddings, default 300 |
| `embeddings.max_text_chars` | integer | Урезка текста перед эмбеддингом, default 4000 |
| `context.guaranteed_messages` | integer | Сколько последних сообщений всегда в контексте, default 10 |
| `context.compression_threshold_messages` | integer | Порог сжатия, default 20 |
| `context.compression_threshold_tokens` | integer | Альтернативный порог по токенам, default 6000 |
| `context.scene_text_truncate_tokens` | integer | Урезка `scene_text` в recent messages, default 500 |
| `context.auto_rag_on_entity_mention` | boolean | Авто-вызов `rag_query` при упоминании сущности, default false |
| `context.safety_margin_tokens` | integer | Резерв от края context window, default 500 |
| `qdrant.url` | string | URL Qdrant-инстанса (например `http://qdrant:6333`) |
| `qdrant.api_key` | string | API-ключ Qdrant (если включена авторизация) |
| `qdrant.collection_prefix` | string | Префикс для коллекций (для multi-tenant деплоя), default `""` |
| `game.deferred_triggers_enabled` | boolean | Включены ли отложенные триггеры, default true |
| `game.max_substeps_per_iteration` | integer | Лимит шагов в Phase 1, default 8 |
| `game.max_suggested_actions` | integer | Лимит действий в конце итерации, default 3 |
| `ui.page_title` | string | Заголовок вкладки браузера |
| `ui.favicon_url` | string | URL favicon (загружается через `/api/admin/upload-icon`, см. §6.5) |
| `ui.logo_url` | string | URL логотипа в шапке приложения |
| `ui.og_image_url` | string | URL Open Graph image (для соц-превью) |
| `admin.setup_token` | string | Токен для создания первого админа |
#### 5.2.3. `world_presets`
Дополнительно спроектированные колонки:
| Колонка | Тип | Назначение |
|---|---|---|
| `is_public` | BOOLEAN, default `false` | Опубликован ли пресет в галерее |
| `version` | INTEGER, default 1 | Версия пресета для контроля обновлений |
#### 5.2.4. `worlds`
| Колонка | Тип | Ограничения | Назначение |
|---|---|---|---|
| `id` | UUID | PK | — |
| `owner_id` | UUID | FK→users.id, NOT NULL | Владелец |
| `preset_id` | UUID | FK→world_presets.id, nullable | Если создан из пресета |
| `name` | VARCHAR(255) | NOT NULL | Название |
| `description` | TEXT | nullable | Описание |
| `language` | VARCHAR(8) | NOT NULL | Код языка (en, ru) |
| `rules` | JSONB | NOT NULL, default `'[]'` | Массив строк правил |
| `time_schema` | JSONB | NOT NULL, default `'{"hours_in_day":24,"initial_date":"day_1_hour_8"}'` | Схема времени |
| `schemas` | JSONB | NOT NULL | Массив схем сущностей |
| `environment_schema` | JSONB | NOT NULL | Массив определений полей окружения |
| `environment` | JSONB | NOT NULL | Живое состояние окружения |
| `plot_rails` | JSONB | NOT NULL, default `'{"hooks":[],"current_goals":[],"completed_goals":[]}'` | Сюжетные рельсы (дублируются из environment для быстрого доступа) |
| `current_time` | VARCHAR(32) | NOT NULL | Текущее игровое время в формате `day_D_hour_H[_min_M]` |
| `status` | VARCHAR(32) | NOT NULL, default `'draft'` | `draft` \| `ready` \| `archived` |
| `intro_scene` | TEXT | nullable | Сгенерированная вступительная сцена |
| `created_at` | TIMESTAMPTZ | NOT NULL, default `now()` | — |
| `updated_at` | TIMESTAMPTZ | NOT NULL, default `now()` | — |
| `last_played_at` | TIMESTAMPTZ | nullable | Последняя итерация |
**Индексы:** `idx_worlds_owner_id`, `idx_worlds_status`, `idx_worlds_last_played_at`.
#### 5.2.5. `entities`
| Колонка | Тип | Ограничения | Назначение |
|---|---|---|---|
| `id` | UUID | PK | — |
| `world_id` | UUID | FK→worlds.id, NOT NULL | Принадлежность миру |
| `entity_type` | VARCHAR(64) | NOT NULL | Тип из `world.schemas` (character, item, location, ...) |
| `name` | VARCHAR(255) | NOT NULL | Имя/название |
| `data` | JSONB | NOT NULL | Полные данные сущности по schema |
| `is_in_environment` | BOOLEAN | NOT NULL, default `false` | Находится ли сущность в environment (для быстрого доступа без JOIN) |
| `qdrant_point_id` | VARCHAR(64) | nullable | ID точки в Qdrant-коллекции `entities`. `NULL` = вектор не посчитан (фоновый job дозаполнит). Текст для эмбеддинга формируется из `name` + JSON `data`. |
| `embedding_status` | VARCHAR(16) | NOT NULL, default `'pending'` | `pending` \| `indexed` \| `failed`. Позволяет фоновому воркеру выбирать «голодные» записи. |
| `created_at` | TIMESTAMPTZ | NOT NULL, default `now()` | — |
| `updated_at` | TIMESTAMPTZ | NOT NULL, default `now()` | — |
| `deleted_at` | TIMESTAMPTZ | nullable | Soft delete. При soft-delete точка в Qdrant тоже удаляется через `qdrant_client.delete()` (best-effort). |
**Индексы:** `idx_entities_world_id`, `idx_entities_world_type` (world_id, entity_type), `idx_entities_embedding_status` (embedding_status) WHERE deleted_at IS NULL — для фонового индексатора. Векторный поиск выполняется в Qdrant, не в PostgreSQL; IVFFLAT/HNSW-индексы здесь не нужны.
#### 5.2.6. `steps`
| Колонка | Тип | Ограничения | Назначение |
|---|---|---|---|
| `id` | UUID | PK | — |
| `world_id` | UUID | FK→worlds.id, NOT NULL | — |
| `sequence_number` | INTEGER | NOT NULL | Монотонный номер шага в мире |
| `player_action` | TEXT | NOT NULL | Действие игрока (или `__suggested__:N` если выбрал заготовку) |
| `scene_text` | TEXT | nullable | Нарратив из Phase 2 |
| `scene_delta_time` | VARCHAR(32) | nullable | Дельта времени из Phase 2 (`[year_Y][days_D][hours_H][min_M]`) |
| `suggested_actions` | JSONB | NOT NULL, default `'[]'` | Массив 1-3 следующих действий |
| `tool_calls_summary` | JSONB | NOT NULL, default `'[]'` | Краткая сводка вызванных инструментов (для UI-пузырьков) |
| `metadata` | JSONB | NOT NULL, default `'{}'` | Доп. метаданные (latency, token counts, model versions) |
| `phase1_log_id` | UUID | FK→llm_call_logs.id, nullable | Лог Phase 1 |
| `phase2_log_id` | UUID | FK→llm_call_logs.id, nullable | Лог Phase 2 |
| `phase3_summary_log_id` | UUID | FK→llm_call_logs.id, nullable | Лог summary (если был) |
| `created_at` | TIMESTAMPTZ | NOT NULL, default `now()` | — |
| `deleted_at` | TIMESTAMPTZ | nullable | Soft delete (для отката) |
**Ограничения:** UNIQUE (world_id, sequence_number) — номера не могут дублироваться.
**Индексы:** `idx_steps_world_seq` (world_id, sequence_number DESC), `idx_steps_created_at`.
#### 5.2.7. `deferred_triggers`
| Колонка | Тип | Ограничения | Назначение |
|---|---|---|---|
| `id` | UUID | PK | — |
| `world_id` | UUID | FK→worlds.id, NOT NULL | — |
| `fire_at` | VARCHAR(32) | NOT NULL | Игровое время срабатывания |
| `event_type` | VARCHAR(64) | NOT NULL | Тип события (например `spawn_enemy`, `weather_change`, `quest_update`) |
| `payload` | JSONB | NOT NULL, default `'{}'` | Данные события |
| `is_fired` | BOOLEAN | NOT NULL, default `false` | Сработало ли |
| `created_at` | TIMESTAMPTZ | NOT NULL, default `now()` | — |
| `fired_at` | TIMESTAMPTZ | nullable | Когда сработало |
**Индексы:** `idx_triggers_world_pending` (world_id, is_fired, fire_at) — для быстрого поиска pending triggers, которые пора активировать.
#### 5.2.8. `story_entries`
| Колонка | Тип | Ограничения | Назначение |
|---|---|---|---|
| `id` | UUID | PK | — |
| `world_id` | UUID | FK→worlds.id, NOT NULL | — |
| `content` | TEXT | NOT NULL | Текст факта |
| `entry_type` | VARCHAR(64) | NOT NULL | `fact` \| `event` \| `relationship` \| `secret` \| `summary` |
| `qdrant_point_id` | VARCHAR(64) | nullable | ID точки в Qdrant-коллекции `story_entries`. `NULL` = вектор ещё не посчитан. |
| `embedding_status` | VARCHAR(16) | NOT NULL, default `'pending'` | `pending` \| `indexed` \| `failed` |
| `metadata` | JSONB | NOT NULL, default `'{}'` | Связи с entity_id, step_id, и т.д. |
| `created_at` | TIMESTAMPTZ | NOT NULL, default `now()` | — |
**Индексы:** `idx_story_world_type` (world_id, entry_type), `idx_story_status` (embedding_status) WHERE qdrant_point_id IS NULL — для фонового индексатора. Векторный поиск — в Qdrant; PostgreSQL хранит только текст и связь с world/entity/step.
#### 5.2.9. `llm_call_logs`
| Колонка | Тип | Ограничения | Назначение |
|---|---|---|---|
| `id` | UUID | PK | — |
| `user_id` | UUID | FK→users.id, nullable | Кто инициировал (nullable для системных вызовов) |
| `world_id` | UUID | FK→worlds.id, nullable | В каком мире |
| `step_id` | UUID | FK→steps.id, nullable | На каком шаге |
| `stage` | VARCHAR(64) | NOT NULL | `world_builder` \| `world_editor` \| `orchestrator_phase1` \| `orchestrator_phase2` \| `orchestrator_phase3_summary` \| `intro_scene` \| `subagent` |
| `model` | VARCHAR(128) | NOT NULL | Имя модели |
| `request_messages` | JSONB | NOT NULL | Полный массив сообщений |
| `request_tools` | JSONB | nullable | Описание tools |
| `response_message` | JSONB | NOT NULL | Ответ LLM |
| `tool_calls` | JSONB | nullable | Извлечённые tool_calls |
| `prompt_tokens` | INTEGER | nullable | — |
| `completion_tokens` | INTEGER | nullable | — |
| `latency_ms` | INTEGER | nullable | — |
| `temperature` | FLOAT | nullable | — |
| `status` | VARCHAR(32) | NOT NULL | `ok` \| `timeout` \| `api_error` \| `parse_error` \| `validation_error` |
| `error_message` | TEXT | nullable | — |
| `created_at` | TIMESTAMPTZ | NOT NULL, default `now()` | — |
**Индексы:** `idx_logs_world_created` (world_id, created_at DESC), `idx_logs_stage` (stage), `idx_logs_status` (status).
#### 5.2.10. `step_tool_calls`
Дочерняя таблица `steps` — детальная запись каждого tool call внутри шага. Нужна для админ-панели и аудита.
| Колонка | Тип | Назначение |
|---|---|---|
| `id` | UUID PK | — |
| `step_id` | UUID FK→steps.id | — |
| `tool_name` | VARCHAR(64) | Имя инструмента |
| `arguments` | JSONB | Аргументы вызова |
| `result` | JSONB | Что вернул инструмент |
| `is_success` | BOOLEAN | Успешен ли вызов |
| `executed_at` | TIMESTAMPTZ | — |
### 5.3. SQL DDL (фрагмент)
Полный DDL генерируется Alembic, но для справки — ключевые таблицы:
```sql
-- pgvector НЕ используется. Векторное хранилище — внешний сервис Qdrant.
-- PostgreSQL хранит только qdrant_point_id (строка-идентификатор точки).
-- users
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(64) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ
);
-- worlds (ключевые поля)
CREATE TABLE worlds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
preset_id UUID REFERENCES world_presets(id) ON DELETE SET NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
language VARCHAR(8) NOT NULL,
rules JSONB NOT NULL DEFAULT '[]'::jsonb,
time_schema JSONB NOT NULL DEFAULT '{"hours_in_day":24,"initial_date":"day_1_hour_8"}'::jsonb,
schemas JSONB NOT NULL,
environment_schema JSONB NOT NULL,
environment JSONB NOT NULL,
plot_rails JSONB NOT NULL DEFAULT '{"hooks":[],"current_goals":[],"completed_goals":[]}'::jsonb,
current_time VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'draft',
intro_scene TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_played_at TIMESTAMPTZ
);
CREATE INDEX idx_worlds_owner_id ON worlds(owner_id);
CREATE INDEX idx_worlds_status ON worlds(status);
CREATE INDEX idx_worlds_last_played_at ON worlds(last_played_at DESC);
-- entities (без вектора; векторы в Qdrant, коллекция `entities`)
CREATE TABLE entities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
world_id UUID NOT NULL REFERENCES worlds(id) ON DELETE CASCADE,
entity_type VARCHAR(64) NOT NULL,
name VARCHAR(255) NOT NULL,
data JSONB NOT NULL,
is_in_environment BOOLEAN NOT NULL DEFAULT false,
qdrant_point_id VARCHAR(64),
embedding_status VARCHAR(16) NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_entities_world_id ON entities(world_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_entities_world_type ON entities(world_id, entity_type) WHERE deleted_at IS NULL;
CREATE INDEX idx_entities_embedding_status ON entities(embedding_status) WHERE deleted_at IS NULL AND qdrant_point_id IS NULL;
-- story_entries (без вектора; векторы в Qdrant, коллекция `story_entries`)
CREATE TABLE story_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
world_id UUID NOT NULL REFERENCES worlds(id) ON DELETE CASCADE,
content TEXT NOT NULL,
entry_type VARCHAR(64) NOT NULL,
qdrant_point_id VARCHAR(64),
embedding_status VARCHAR(16) NOT NULL DEFAULT 'pending',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_story_world_type ON story_entries(world_id, entry_type);
CREATE INDEX idx_story_status ON story_entries(embedding_status) WHERE qdrant_point_id IS NULL;
-- llm_call_logs
CREATE TABLE llm_call_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
world_id UUID REFERENCES worlds(id) ON DELETE SET NULL,
step_id UUID REFERENCES steps(id) ON DELETE SET NULL,
stage VARCHAR(64) NOT NULL,
model VARCHAR(128) NOT NULL,
request_messages JSONB NOT NULL,
request_tools JSONB,
response_message JSONB NOT NULL,
tool_calls JSONB,
prompt_tokens INTEGER,
completion_tokens INTEGER,
latency_ms INTEGER,
temperature FLOAT,
status VARCHAR(32) NOT NULL,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_logs_world_created ON llm_call_logs(world_id, created_at DESC);
CREATE INDEX idx_logs_stage ON llm_call_logs(stage);
CREATE INDEX idx_logs_status ON llm_call_logs(status);
```
### 5.4. Миграции
- **Инструмент:** Alembic.
- **Директория:** `app/migrations/versions/`.
- **Первая миграция:** `001_initial_schema.py` создаёт все таблицы. pgvector НЕ нужен. Дополнительно к миграции БД — startup-хук `app/migrations/init_qdrant.py` создаёт коллекции `entities` и `story_entries` в Qdrant с правильной размерностью (см. §11.6, авто-проба размерности).
- **Seed-скрипт:** `app/migrations/seed.py` заполняет `settings` (значения по умолчанию) и встроенные `world_presets` (минимум 2: фэнтези и sci-fi).
- **Rollback:** каждая миграция должна иметь `downgrade()`. Перед деплоем — обязательный прогон `alembic downgrade --sql +1` в staging для проверки.
**Порядок создания таблиц (для миграции 001):**
1. `users` (нет FK)
2. `settings` (нет FK)
3. `world_presets` (FK→users)
4. `worlds` (FK→users, world_presets)
5. `entities` (FK→worlds)
6. `story_entries` (FK→worlds)
7. `deferred_triggers` (FK→worlds)
8. `llm_call_logs` (FK→users, worlds)
9. `steps` (FK→worlds, llm_call_logs)
10. `step_tool_calls` (FK→steps)
11. Все индексы и constraints
---
## 6. API спецификация (OpenAPI)
Базовый префикс всех маршрутов: `/api`. Все эндпоинты, кроме `/api/auth/*` и `/api/register/*`, требуют заголовок `Authorization: Bearer `. Ответы — JSON. Ошибки — в формате `{error: {code: string, message: string, details?: object}}`.
### 6.1. Аутентификация и регистрация
#### `POST /api/register`
Регистрация нового пользователя. Открывать только если в БД нет ни одного админа — иначе нужен admin-token.
**Request body:**
```json
{
"email": "user@example.com",
"username": "player1",
"password": "secret123",
"password_confirm": "secret123"
}
```
**Response 201:**
```json
{
"id": "uuid",
"email": "user@example.com",
"username": "player1",
"is_admin": false,
"created_at": "2026-06-20T10:00:00Z"
}
```
**Ошибки:** `400 email_already_exists`, `400 username_already_exists`, `400 password_mismatch`, `400 weak_password`.
#### `POST /api/register/admin`
Регистрация администратора. Требует query-параметр `?token=`. Если токен не совпадает с `admin.setup_token` из `settings` — `403`.
#### `POST /api/auth/login`
**Request body:**
```json
{
"login": "user@example.com", // email ИЛИ username
"password": "secret123"
}
```
**Response 200:**
```json
{
"access_token": "eyJ...",
"token_type": "bearer",
"expires_in": 86400,
"user": { "id": "uuid", "username": "player1", "is_admin": false }
}
```
**Ошибки:** `401 invalid_credentials`, `403 account_disabled`.
#### `POST /api/auth/refresh`
Обновление токена. Требует текущий валидный JWT. Возвращает новый.
#### `POST /api/auth/logout`
Инвалидирует токен (добавляет в blacklist до истечения). Возвращает `204 No Content`.
#### `GET /api/auth/me`
Возвращает профиль текущего пользователя.
### 6.2. Миры (Worlds)
#### `GET /api/worlds`
Список миров текущего пользователя. Поддержка pagination.
**Query:** `?page=1&per_page=20&status=ready&sort=last_played_at`.
**Response 200:**
```json
{
"items": [
{
"id": "uuid",
"name": "Тёмное подземелье",
"description": "...",
"language": "ru",
"status": "ready",
"last_played_at": "2026-06-19T20:00:00Z",
"current_time": "day_3_hour_14_min_30",
"preview_player_name": "Эрик"
}
],
"total": 42,
"page": 1,
"per_page": 20
}
```
#### `POST /api/worlds`
Создание мира. Запускает асинхронный процесс `world_builder` (см. [раздел 9.1](#91-поток-создания-мира-world_builder)). Возвращает `world_id` и SSE-канал.
**Request body:**
```json
{
"mode": "preset", // "preset" | "form"
"preset_id": "uuid", // если mode=preset
"form_data": { // если mode=form
"setting": "post-apocalyptic underground bunker",
"rules": ["..."],
"character_concept": "lone scavenger"
},
"name": "Мой бункер",
"language": "ru",
"player_name": "Эрик",
"notes": "Хочу упор на survival horror"
}
```
**Response 202:**
```json
{
"world_id": "uuid",
"stream_url": "/api/sessions/worlds/uuid/builder/stream"
}
```
#### `GET /api/worlds/{id}`
Полные данные мира (для страницы редактирования/игры). Включает `schemas`, `environment_schema`, `environment`, `plot_rails`, `current_time`.
#### `PATCH /api/worlds/{id}`
Ручное обновление полей мира (только owner). Используется в world_editor для прямых правок JSON.
**Request body:** partial World object.
#### `DELETE /api/worlds/{id}`
Soft delete: ставит `status='archived'`. Hard delete — только через админ-панель.
#### `POST /api/worlds/{id}/edit`
Запускает world_editor stream. **Request body:**
```json
{ "instruction": "Добавь игроку меч в инвентарь" }
```
**Response 202:** `{ "stream_url": "/api/sessions/worlds/uuid/editor/stream" }`
### 6.3. Сессии (Sessions) — игровая итерация
#### `GET /api/sessions/worlds/{id}/state`
Возвращает текущее состояние для рендера страницы игры: последние N шагов, environment, suggested_actions.
**Response 200:**
```json
{
"world": { "id": "uuid", "name": "...", "current_time": "..." },
"environment": { "player": {...}, "current_location": "...", "plot_rails": {...} },
"recent_steps": [ { "id": "uuid", "sequence_number": 42, "scene_text": "...", "suggested_actions": [...] } ],
"next_actions": ["Открыть дверь", "Осмотреть комнату", "Подойти к окну"]
}
```
#### `POST /api/sessions/worlds/{id}/iterate`
Запускает orchestrator (3 фазы). Возвращает SSE URL.
**Request body:**
```json
{
"action": "Я открываю дверь мечом",
"action_source": "custom" // "custom" | "suggested"
}
```
**Response 202:**
```json
{ "stream_url": "/api/sessions/worlds/uuid/iterate/stream", "step_id": "uuid" }
```
#### `POST /api/sessions/worlds/{id}/retry`
Повторная генерация последнего шага (если предыдущая упала). Использует тот же `action`.
#### `POST /api/sessions/worlds/{id}/rollback`
Откат последнего шага. Soft-deletes последний `step`, восстанавливает предыдущее состояние environment из истории.
### 6.4. Пресеты (Presets)
#### `GET /api/presets`
Список публичных пресетов (status=ready AND is_public=true) + пресеты текущего пользователя.
#### `POST /api/presets` (admin only)
Создание нового пресета.
#### `GET /api/presets/{id}`
Полные данные пресета.
#### `PATCH /api/presets/{id}` (owner/admin)
Редактирование пресета.
#### `DELETE /api/presets/{id}` (owner/admin)
Архивация пресета.
### 6.5. Админка
Все эндпоинты требуют `is_admin=true`.
#### `GET /api/admin/settings`
Возвращает все настройки (кроме секретных значений, которые маскируются).
#### `PATCH /api/admin/settings`
Обновление настроек. **Request body:** `{ "llm.api_url": "http://...", "llm.model": "..." }`.
#### `GET /api/admin/llm-logs`
Логи вызовов LLM с фильтрами. **Query:** `?world_id=&stage=&status=&page=&per_page=&from=&to=`.
#### `GET /api/admin/llm-logs/{id}`
Полный лог: `request_messages`, `response_message`, `tool_calls`, `error_message`.
#### `GET /api/admin/users`
Список пользователей. **PATCH** — изменение `is_admin`, `is_active`.
#### `GET /api/admin/stats`
Сводная статистика: количество пользователей, миров, итераций за период, средний latency LLM.
#### Тестовые эндпоинты (диагностика подключений)
Эти эндпоинты доступны только админу (`is_admin=true`). Принимают **query params** (а не body) — это позволяет проверить настройки до сохранения в `settings`. Каждый эндпоинт возвращает `{ok: bool, elapsed_ms: int, ...детали}` и логирует результат в `llm_call_logs` со stage=`test_*`.
##### `POST /api/admin/test/llm?api_url=&api_key=&model=`
Проверяет базовую связность с LLM. Отправляет промпт `"Reply with exactly: OK"` (max_tokens=10, temperature=0). **Возвращает:**
```json
{
"ok": true,
"response": "OK",
"model": "qwen2.5-7b-instruct",
"elapsed_ms": 412,
"prompt_tokens": 12,
"completion_tokens": 2
}
```
При ошибке: `{"ok": false, "error": {"code": "connection_failed", "message": "..."}, "elapsed_ms": 5000}`.
##### `POST /api/admin/test/llm-tools?api_url=&api_key=&model=`
Проверяет, что LLM корректно вызывает инструменты (function calling). Отправляет промпт `"What is 2+2? Use the calc tool."` + один инструмент `calc(expression)`. **Возвращает:**
```json
{
"ok": true,
"tool_calls": [{"name": "calc", "arguments": {"expression": "2+2"}}],
"elapsed_ms": 580,
"has_tool_calls": true
}
```
Если `has_tool_calls=false` — модель не поддерживает function calling в текущей конфигурации; админу показывается warning.
##### `POST /api/admin/test/embeddings?api_url=&api_key=&model=&provider=`
Проверяет эмбеддинг-провайдера. Отправляет `"hello world"` в embeddings API. **Возвращает:**
```json
{
"ok": true,
"dimension": 1536,
"model": "text-embedding-3-small",
"first_5_values": [0.0123, -0.0456, 0.0789, -0.0321, 0.0543],
"elapsed_ms": 142
}
```
Если `provider=offline_hash` — endpoint не делает HTTP-запрос, возвращает dimension из локального `HashEmbedder`.
##### `POST /api/admin/test/embeddings/probe-dimension?api_url=&api_key=&model=&provider=`
То же что и `/test/embeddings`, но возвращает **только dimension** — используется UI-кнопкой «Авто-проба размерности» (§12.4). **Возвращает:** `{"ok": true, "dimension": 1536, "elapsed_ms": 142}`. После этого UI предлагает кнопку «Сохранить 1536 в `embeddings.dimension`».
#### Загрузка иконки (favicon/logo)
##### `POST /api/admin/upload-icon`
Принимает `multipart/form-data` с полем `file` (PNG/SVG, до 1MB) и опциональным `kind` (`favicon` | `logo` | `og_image`). Сохраняет файл в `${DATA_DIR}/assets/{kind}_{timestamp}.{ext}`, обновляет `settings.ui.favicon_url` (или `ui.logo_url` / `ui.og_image_url`) на относительный URL `/static/assets/{kind}_{timestamp}.{ext}`. **Возвращает:**
```json
{
"ok": true,
"kind": "favicon",
"url": "/static/assets/favicon_20260620_142312.png",
"size_bytes": 12345
}
```
Статические файлы из `${DATA_DIR}/assets/` раздаются FastAPI через `StaticFiles` mount на `/static/assets`.
### 6.6. Misc
#### `GET /api/health`
Без авторизации. `{ "status": "ok", "db": true, "llm": true, "version": "1.0.0" }`.
#### `GET /api/i18n/{lang}`
Возвращает JSON с переводами для языка (используется фронтендом для ленивой подгрузки).
### 6.7. Стандартные коды ошибок
| HTTP | code | Когда |
|---|---|---|
| 400 | `validation_error` | Pydantic-валидация не прошла |
| 400 | `password_mismatch` | `password != password_confirm` |
| 400 | `weak_password` | Пароль < 8 символов или в blacklist |
| 401 | `invalid_credentials` | Неверный логин/пароль |
| 401 | `token_expired` | JWT истёк |
| 401 | `token_invalid` | JWT невалиден |
| 403 | `account_disabled` | `is_active=false` |
| 403 | `not_admin` | Требуется админ |
| 403 | `not_owner` | Не владелец ресурса |
| 404 | `not_found` | Ресурс не найден |
| 409 | `state_conflict` | Оптимистичная блокировка не прошла |
| 422 | `world_invalid` | `validate_world()` упал |
| 429 | `rate_limited` | Превышен лимит (см. NFR §15.2) |
| 500 | `internal_error` | Необработанная ошибка |
| 502 | `llm_unavailable` | LLM-провайдер недоступен |
| 504 | `llm_timeout` | LLM-вызов превысил timeout |
---
## 7. SSE-протокол
SSE (Server-Sent Events) используется для всех долгих операций: `world_builder`, `world_editor`, `orchestrator`, `intro_scene`. Все SSE-каналы — односторонние (server→client), для команд от клиента используется REST.
### 7.1. Заголовки и подключение
```
GET /api/sessions/worlds/{id}/iterate/stream
Accept: text/event-stream
Authorization: Bearer
Cache-Control: no-cache
```
Каждое событие:
```
event:
data:
```
Двойной `\n` обязателен. Heartbeat каждые 15 секунд:
```
event: ping
data: {"ts": "2026-06-20T10:00:00Z"}
```
### 7.2. Универсальные события
Эти события могут прийти в любом SSE-канале:
| Event | Data | Назначение |
|---|---|---|
| `ping` | `{ts}` | Heartbeat, чтобы соединение не закрылось |
| `error` | `{code, message, details?}` | Фатальная ошибка, стрим закрывается |
| `warning` | `{code, message}` | Некритичная проблема, стрим продолжается |
| `progress` | `{phase, step, total_steps?, message?}` | Прогресс текущей фазы |
| `done` | `{result}` | Успешное завершение, стрим закрывается |
### 7.3. События orchestrator (итерация сессии)
| Event | Data | Когда |
|---|---|---|
| `phase_start` | `{phase: 1\|2\|3, name}` | Начало фазы |
| `phase_end` | `{phase, duration_ms}` | Конец фазы |
| `tool_call` | `{tool, arguments, result?, is_success}` | LLM вызвала инструмент |
| `llm_call_start` | `{stage, model}` | Начало LLM-вызова |
| `llm_call_end` | `{stage, latency_ms, tokens}` | Конец LLM-вызова |
| `scene_chunk` | `{text}` | Streaming-чанк текста из Phase 2 |
| `scene_complete` | `{text, delta_time}` | Полный текст сцены |
| `suggested_actions` | `{actions: [...]}` | 1-3 следующих действия |
| `trigger_fired` | `{trigger_id, event_type, summary}` | Сработал отложенный триггер |
| `summary_generated` | `{summary_id, message_range}` | Сгенерирован summary |
| `iteration_complete` | `{step_id, sequence_number}` | Полное завершение |
### 7.4. События world_builder
| Event | Data |
|---|---|
| `step` | `{step: "generating_schema", message: "..."}` |
| `world_schema_generated` | `{schemas, environment_schema}` |
| `environment_generated` | `{environment}` |
| `entities_generated` | `{entities: [...]}` |
| `intro_scene_chunk` | `{text}` |
| `intro_scene_complete` | `{text, suggested_actions}` |
### 7.5. События world_editor
| Event | Data |
|---|---|
| `llm_thinking` | `{}` |
| `clarification` | `{question, options?}` | LLM спрашивает игрока через `ask_user` |
| `change_proposed` | `{diff: [{path, op, old, new}]}` |
| `comment` | `{text}` | Комментарий LLM игроку |
| `apply_changes` | `{}` | Игрок принял, изменения применены |
| `discard_changes` | `{}` | Игрок отклонил |
### 7.6. Reconnect-стратегия
- Фронтенд использует `EventSource` (нативный) с автоконнектом.
- При reconnect клиент шлёт `Last-Event-ID` header — сервер возобновляет с пропущенных событий.
- Если `Last-Event-ID` старше 5 минут — сервер отвечает `410 Gone`, клиент делает полный re-fetch через REST.
- Каждое событие имеет `id` поле для `Last-Event-ID`:
```
id: step_42_tool_3
event: tool_call
data: {...}
```
### 7.7. Close-коды (если SSE через WebSocket-fallback)
Не используется в текущей архитектуре (только нативный EventSource), но зарезервировано для будущего:
- 4000 — unauthorized
- 4001 — world_not_found
- 4002 — rate_limited
- 4003 — server_shutdown
---
## 8. Tool-сигнатуры (JSON-schema)
Это **центральный контракт** системы. Все инструменты реализованы как Python-функции в `app/engine/tools/`, и регистрируются в `app/engine/tools/registry.py`. LLM получает их описание через `LlmClient` в параметре `tools` OpenAI-формата.
**Категории инструментов:**
| Категория | Когда вызываются | Примеры |
|---|---|---|
| **Game tools** | Внутри Phase 1 orchestrator (между `submit_step`). Мутируют состояние мира. | `entity_create`, `entity_get`, `entity_list`, `entity_update`, `entity_delete`, `env_update`, `env_get`, `rag_query`, `rag_add`, `schedule_trigger`, `advance_time`, `calc`, `run_subagent`, `update_plot_rails`, `random_choice`, `submit_plan`, `submit_step` |
| **Interaction tools** | В `world_builder` / `world_editor` диалогах. Коммуницируют с игроком. | `ask_user`, `propose_changes`, `comment_to_user` |
| **Schema tools** | В `world_editor` для редактирования схем. | `schema_add_field`, `schema_remove_field`, `schema_modify_field`, `schema_add_type` |
### 8.1. Общий формат ответа инструмента
Все инструменты возвращают JSON-объект с одинаковой структурой:
```json
{
"ok": true,
"data": { ... },
"message": "человекочитаемое описание для LLM"
}
```
или в случае ошибки:
```json
{
"ok": false,
"error": {
"code": "validation_error",
"message": "player.stats.health must be >= 0, got -5"
}
}
```
Этот формат возвращается LLM как `tool_result` сообщение. LLM видит и `message` (для самокоррекции), и `data` (для использования в следующих шагах).
### 8.2. Game tools — детальные сигнатуры
#### 8.2.1. `entity_create`
Создаёт новую сущность в мире.
```json
{
"name": "entity_create",
"description": "Создаёт новую сущность в текущем мире. Тип должен существовать в world.schemas. data должна соответствовать schema этого типа.",
"parameters": {
"type": "object",
"required": ["entity_type", "name", "data"],
"properties": {
"entity_type": {
"type": "string",
"description": "Тип сущности из world.schemas (character, item, location, faction, ...)"
},
"name": {
"type": "string",
"description": "Имя/название сущности (уникально в пределах (world_id, entity_type))"
},
"data": {
"type": "object",
"description": "Полные данные сущности по schema"
},
"add_to_environment": {
"type": "boolean",
"default": false,
"description": "Добавить ли сущность в environment (для быстрого доступа LLM)"
}
}
}
}
```
**Возвращает:**
```json
{ "ok": true, "data": { "entity_id": "uuid" }, "message": "Создан character 'Элара'" }
```
**Ошибки:** `unknown_entity_type`, `name_conflict`, `schema_violation`, `world_not_found`.
#### 8.2.2. `entity_get`
Получает сущность по ID или по (entity_type, name).
```json
{
"name": "entity_get",
"parameters": {
"required": ["query"],
"properties": {
"query": {
"oneOf": [
{ "type": "string", "description": "entity_id (UUID)" },
{
"type": "object",
"properties": {
"entity_type": { "type": "string" },
"name": { "type": "string" }
},
"required": ["entity_type", "name"]
}
]
}
}
}
}
```
#### 8.2.3. `entity_list`
Список сущностей с фильтром.
```json
{
"name": "entity_list",
"parameters": {
"properties": {
"entity_type": { "type": "string", "description": "Фильтр по типу. Если не указан — все типы" },
"in_environment_only": { "type": "boolean", "default": false },
"name_contains": { "type": "string" },
"limit": { "type": "integer", "default": 50, "max": 200 }
}
}
}
```
#### 8.2.4. `entity_update`
Обновляет поля сущности через JSON-patch.
```json
{
"name": "entity_update",
"parameters": {
"required": ["entity_id", "patch"],
"properties": {
"entity_id": { "type": "string" },
"patch": {
"type": "object",
"description": "JSON-patch: {field_path: new_value} или {field_path: {op: 'inc', by: N}}",
"example": { "stats.health": { "op": "inc", "by": -10 }, "description": "Ранен" }
}
}
}
}
```
#### 8.2.5. `entity_delete`
Soft delete. Помечает `deleted_at=now()`.
```json
{
"name": "entity_delete",
"parameters": {
"required": ["entity_id"],
"properties": {
"entity_id": { "type": "string" },
"reason": { "type": "string", "description": "Почему удаляется (для лога)" }
}
}
}
```
#### 8.2.6. `env_update`
**Ключевой инструмент** — мутирует environment через валидируемый patch.
```json
{
"name": "env_update",
"description": "Применяет JSON-patch к environment. Patch проходит через state_validator. Если валидация провалилась — patch не применяется, LLM получает ошибку и может попробовать ещё раз.",
"parameters": {
"required": ["patch"],
"properties": {
"patch": {
"type": "object",
"description": "Карта field_path -> new_value | {op, by}. Поддерживаемые op: 'inc', 'dec', 'set', 'append', 'remove'.",
"example": {
"player.stats.health": { "op": "inc", "by": -10 },
"player.stats.mana": { "op": "inc", "by": -5 },
"player.inventory": { "op": "append", "value": { "item_id": "sword_01", "qty": 1 } }
}
}
}
}
}
```
**Возвращает:**
```json
{
"ok": true,
"data": { "applied_paths": ["player.stats.health", "player.stats.mana"] },
"message": "Environment обновлён"
}
```
#### 8.2.7. `env_get`
Возвращает текущее значение поля environment (или весь environment если `path` не указан).
```json
{
"name": "env_get",
"parameters": {
"properties": {
"path": { "type": "string", "description": "Например 'player.stats' или 'plot_rails.current_goals'" }
}
}
}
```
#### 8.2.8. `update_plot_rails`
Специализированный инструмент для управления сюжетными рельсами.
```json
{
"name": "update_plot_rails",
"parameters": {
"required": ["operation"],
"properties": {
"operation": {
"type": "string",
"enum": ["add_hook", "remove_hook", "add_goal", "remove_goal", "complete_goal"]
},
"value": { "type": "string", "description": "Текст хука/цели" },
"index": { "type": "integer", "description": "Для remove_* операций" }
}
}
}
```
#### 8.2.9. `rag_query`
Семантический поиск по `story_entries` и `entities` через Qdrant. Сначала векторы запроса и документов сравниваются в Qdrant (с payload-фильтром по `world_id`), затем полные данные подтягиваются из PostgreSQL по IDs (см. §11.1.4).
```json
{
"name": "rag_query",
"description": "Semantic search over entities and story entries. Use when you need to recall past details, NPC names, world facts, or find an entity by description. Do NOT try to recall from memory — you may hallucinate.",
"parameters": {
"required": ["query"],
"properties": {
"query": { "type": "string", "description": "Natural-language search query." },
"limit": { "type": "integer", "default": 5, "max": 20 },
"filter_type": { "type": "string", "enum": ["all", "entities", "story_entries"], "default": "all" },
"min_score": { "type": "number", "default": 0.7, "description": "Minimum cosine similarity (0..1)." }
}
}
}
```
**Возвращает:**
```json
{
"ok": true,
"data": {
"results": [
{ "type": "entity", "id": "uuid", "score": 0.89, "content": { "entity_type": "character", "name": "...", "data": {...} } },
{ "type": "story_entry", "id": "uuid", "score": 0.82, "content": { "text": "Игрок убил дракона в день 3" } }
]
}
}
```
#### 8.2.10. `rag_add`
Добавляет факт в `story_entries` и индексирует его в Qdrant-коллекции `story_entries` (см. §11.1.5). Если embeddings API недоступен — запись сохраняется с `embedding_status='pending'`, фоновый индексатор досчитает вектор позже.
```json
{
"name": "rag_add",
"description": "Persist a fact/event as a story entry and index it for semantic search. Use when the player learns a new persistent fact (NPC secret, world lore, quest outcome) that should be recallable later via rag_query.",
"parameters": {
"required": ["content", "entry_type"],
"properties": {
"content": { "type": "string", "description": "Fact text. Will be truncated to 4000 chars before embedding." },
"entry_type": { "type": "string", "enum": ["fact", "event", "relationship", "secret"] },
"metadata": { "type": "object", "description": "Links: entity_id, step_id, etc." }
}
}
}
```
#### 8.2.11. `schedule_trigger`
Планирует отложенное событие.
```json
{
"name": "schedule_trigger",
"description": "Планирует событие на игровое время. Сработает автоматически когда current_time >= fire_at.",
"parameters": {
"required": ["fire_at", "event_type", "payload"],
"properties": {
"fire_at": { "type": "string", "description": "Формат: [year_Y_]day_D_hour_H[_min_M]. Пример: 'day_5_hour_12'" },
"event_type": { "type": "string", "enum": ["spawn_enemy", "weather_change", "quest_update", "npc_action", "custom"] },
"payload": { "type": "object", "description": "Данные события. Структура зависит от event_type." }
}
}
}
```
#### 8.2.12. `advance_time`
Принудительное продвижение времени (альтернатива — `submit_step` с полем `delta_time`).
```json
{
"name": "advance_time",
"parameters": {
"required": ["delta"],
"properties": {
"delta": { "type": "string", "description": "Формат: [year_Y][days_D][hours_H][min_M]. Пример: 'hours_2_min_30'" }
}
}
}
```
#### 8.2.13. `calc`
Калькулятор для боевых и механических вычислений. Не позволяет LLM ошибиться в арифметике.
```json
{
"name": "calc",
"description": "Вычисляет математическое выражение. Используй для боёв, бросков кубиков, расчёта урона.",
"parameters": {
"required": ["expression"],
"properties": {
"expression": {
"type": "string",
"description": "Выражение в безопасном DSL. Поддерживаются +, -, *, /, %, d (бросок кубика: 2d6+3), min(), max(), round()",
"example": "max(1, 2d6+3 - enemy.armor)"
},
"variables": {
"type": "object",
"description": "Контекст для подстановки переменных",
"example": { "enemy.armor": 5 }
}
}
}
}
```
**Возвращает:** `{ "ok": true, "data": { "result": 8, "rolls": [3, 5], "trace": "max(1, 8-5)=3" } }`
#### 8.2.14. `random_choice`
Детерминированный (с seed) выбор из вариантов. Seed = `hash(world_id + step_id + choice_index)` для воспроизводимости.
```json
{
"name": "random_choice",
"parameters": {
"required": ["options"],
"properties": {
"options": { "type": "array", "items": {}, "minItems": 2 },
"weights": { "type": "array", "items": { "type": "number" } }
}
}
}
```
#### 8.2.15. `run_subagent`
Запускает вложенный LLM-вызов для офэкранных действий (Phase 3.1).
```json
{
"name": "run_subagent",
"description": "Запускает вложенный LLM-вызов с собственным промптом для офэкранных действий (например, что происходит в соседней комнате пока игрок здесь).",
"parameters": {
"required": ["task", "tools"],
"properties": {
"task": { "type": "string", "description": "Описание задачи для subagent" },
"tools": {
"type": "array",
"items": { "type": "string" },
"description": "Список имён инструментов, доступных subagent"
},
"context": {
"type": "object",
"description": "Дополнительный контекст (entity_id, location, ...)"
},
"max_iterations": { "type": "integer", "default": 5, "max": 10 }
}
}
}
```
#### 8.2.16. `submit_plan` (Phase 1 завершение)
Завершает Phase 1, передаёт план действий в Phase 2.
```json
{
"name": "submit_plan",
"description": "Завершает Phase 1 планирования. Передаёт план и краткую сводку действий в Phase 2 (writer).",
"parameters": {
"required": ["plan", "summary"],
"properties": {
"plan": {
"type": "string",
"description": "Что произошло в этой итерации (для writer)"
},
"summary": {
"type": "array",
"items": { "type": "object" },
"description": "Краткая сводка вызванных инструментов: [{tool, result_summary}]",
"example": [
{ "tool": "entity_create", "result_summary": "Создан враг 'Гоблин'" },
{ "tool": "env_update", "result_summary": "player.stats.health -10" }
]
},
"offscreen_events": {
"type": "array",
"items": { "type": "string" },
"description": "Заэкранные события для subagent в Phase 3.1"
}
}
}
}
```
#### 8.2.17. `submit_step` (Phase 2 завершение)
Финальный инструмент Phase 2 — writer возвращает сцену.
```json
{
"name": "submit_step",
"description": "Завершает Phase 2. Writer возвращает финальный нарратив и дельту времени.",
"parameters": {
"required": ["scene_text", "delta_time"],
"properties": {
"scene_text": {
"type": "string",
"description": "Нарратив итерации. Минимум 100 символов, максимум 4000."
},
"delta_time": {
"type": "string",
"description": "Сколько игрового времени заняла итерация. Формат: [year_Y][days_D][hours_H][min_M]."
}
}
}
}
```
#### 8.2.18. `suggest_actions` (Phase 3.2 завершение)
Генерирует 1-3 следующих действия для игрока.
```json
{
"name": "suggest_actions",
"parameters": {
"required": ["actions"],
"properties": {
"actions": {
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"maxItems": 3,
"description": "1-3 коротких действия на языке мира"
}
}
}
}
```
### 8.3. Interaction tools
#### 8.3.1. `ask_user`
Запрос уточнения у игрока в world_builder / world_editor.
```json
{
"name": "ask_user",
"parameters": {
"required": ["question"],
"properties": {
"question": { "type": "string" },
"options": {
"type": "array",
"items": { "type": "string" },
"description": "Опциональные варианты ответа"
},
"allow_free_text": { "type": "boolean", "default": true }
}
}
}
```
**Блокирует** поток до ответа игрока (через SSE `clarification` event + REST `/api/sessions/.../answer`).
#### 8.3.2. `propose_changes` (world_editor)
Предлагает изменения игроку на accept/reject.
```json
{
"name": "propose_changes",
"parameters": {
"required": ["diff"],
"properties": {
"diff": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": { "type": "string", "example": "schemas[1].properties[2].verbose" },
"op": { "type": "string", "enum": ["add", "remove", "replace"] },
"old": {},
"new": {}
}
}
},
"comment": { "type": "string", "description": "Почему эти изменения" }
}
}
}
```
#### 8.3.3. `comment_to_user`
Просто текстовый комментарий в чат (не требует ответа).
```json
{
"name": "comment_to_user",
"parameters": {
"required": ["text"],
"properties": { "text": { "type": "string" } }
}
}
```
### 8.4. Schema tools (для world_editor)
#### 8.4.1. `schema_add_type`
Добавляет новый тип сущности в `world.schemas`.
```json
{
"name": "schema_add_type",
"parameters": {
"required": ["type", "verbose", "plural", "properties"],
"properties": {
"type": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
"verbose": { "type": "string" },
"plural": { "type": "string" },
"properties": { "type": "array", "items": { /* property definition */ } }
}
}
}
```
#### 8.4.2. `schema_add_field`, `schema_remove_field`, `schema_modify_field`
Аналогично — для редактирования `properties` существующих типов.
### 8.5. Реестр инструментов
Все инструменты регистрируются в `app/engine/tools/registry.py`:
```python
from app.engine.tools.base import Tool, ToolContext, ToolResult
class ToolRegistry:
def __init__(self):
self._tools: dict[str, Tool] = {}
def register(self, tool: Tool) -> None:
self._tools[tool.name] = tool
def get(self, name: str) -> Tool | None:
return self._tools.get(name)
def list_for_stage(self, stage: str) -> list[Tool]:
"""Возвращает инструменты, доступные на данной стадии."""
...
def to_openai_format(self, stage: str) -> list[dict]:
"""Сериализует в OpenAI tools format."""
...
```
**Доступность инструментов по стадиям:**
| Stage | Доступные инструменты |
|---|---|
| `world_builder` | `ask_user`, `comment_to_user`, `schema_add_type`, `schema_add_field`, `entity_create`, `env_update`, `submit_plan` |
| `world_editor` | `ask_user`, `comment_to_user`, `propose_changes`, `schema_*`, `entity_*`, `env_update`, `rag_query` |
| `orchestrator_phase1` | Все game tools, кроме `submit_step` и `suggest_actions` |
| `orchestrator_phase2` | `submit_step` (единственный) |
| `orchestrator_phase3_summary` | `submit_summary` |
| `orchestrator_phase3_suggest` | `suggest_actions` (единственный) |
| `intro_scene` | `submit_step`, `suggest_actions` |
| `subagent` | `entity_*`, `env_get`, `env_update`, `rag_query` |
### 8.6. Контракт выполнения
Каждый tool вызывается через `ToolRegistry.execute()`:
```python
async def execute(
self,
name: str,
arguments: dict,
ctx: ToolContext,
) -> ToolResult:
"""
ctx содержит: world, session, user_id, step_id, stage, sse_emitter.
Возвращает ToolResult с ok/data/message или ok=false/error.
Логирует вызов в step_tool_calls.
Эмитит SSE событие tool_call.
"""
```
---
## 9. Потоки (flows) с sequence-диаграммами
В системе семь ключевых потоков. Каждый поток имеет свой system-промпт, свой набор инструментов и свой SSE-протокол. **Каждый поток изолирован** — сообщения одного потока не видны другому, чтобы предотвратить галлюцинации.
### 9.1. Поток создания мира (world_builder)
Запускается при `POST /api/worlds`. Создаёт новый мир из пресета или формы, генерирует схемы, environment, начальные сущности и вступительную сцену.
**Шаги:**
1. Получение шаблона мира (built-in / опубликованный / form).
2. Игрок заполняет: название, язык, своего персонажа, заметки.
3. Первоначальная генерация мира (LLM генерирует `schemas`, `environment_schema`, `environment` с `player`).
4. Редактирование (через `world_editor` flow).
5. Догенерация начального состояния (локации, персонажи, предметы, цели).
6. Генерация вступительной сцены + 1-3 действий.
7. Игрок нажимает "Начать игру" → переход на страницу мира.
```mermaid
sequenceDiagram
autonumber
participant U as Игрок
participant FE as Frontend
participant API as FastAPI
participant WB as WorldBuilder
participant LLM as LLM
participant DB as PostgreSQL
U->>FE: Заполняет форму / выбирает пресет
FE->>API: POST /api/worlds {mode, preset_id|form_data, name, language, player_name, notes}
API->>DB: INSERT world (status=draft)
API-->>FE: 202 {world_id, stream_url}
FE->>API: GET /api/sessions/worlds/{id}/builder/stream (SSE)
Note over WB: Шаг 1: Генерация схем
WB->>LLM: prompt(world_builder_schema) + form_data
LLM-->>WB: {schemas, environment_schema, rules, time_schema}
WB->>DB: UPDATE world SET schemas=..., environment_schema=...
WB-->>FE: SSE: world_schema_generated
Note over WB: Шаг 2: Генерация environment (player)
WB->>LLM: prompt(world_builder_env) + schemas + player_name
LLM-->>WB: {environment: {player, current_location, plot_rails}}
WB->>DB: UPDATE world SET environment=..., plot_rails=...
WB-->>FE: SSE: environment_generated
Note over WB: Шаг 3: Догенерация сущностей
WB->>LLM: prompt(world_builder_entities) + environment
LLM->>WB: tool_call(entity_create, location "Таверна")
WB->>DB: INSERT entity
WB-->>LLM: tool_result(ok)
LLM->>WB: tool_call(entity_create, character "Трактирщик")
WB->>DB: INSERT entity
WB-->>LLM: tool_result(ok)
LLM->>WB: tool_call(submit_plan)
WB-->>FE: SSE: entities_generated
Note over WB: Шаг 4: Генерация вступительной сцены
WB->>LLM: prompt(intro_scene) + environment + plot_rails
LLM-->>WB: tool_call(submit_step, {scene_text, delta_time})
WB-->>FE: SSE: intro_scene_chunk (streaming)
WB->>LLM: prompt(suggest_actions)
LLM-->>WB: tool_call(suggest_actions, {actions: [...]})
WB->>DB: UPDATE world SET intro_scene=..., status='ready'
WB-->>FE: SSE: intro_scene_complete + done
FE->>U: Показывает сцену + кнопку "Начать игру"
```
**Edge-cases:**
- LLM вернул невалидный schema → `state_validator` возвращает ошибку → LLM получает `tool_result` с ошибкой → пробует ещё раз (до 3 попыток). Если не вышло — SSE `error` с кодом `schema_generation_failed`.
- LLM не вызвала `submit_plan` за `max_substeps` → WB форсит завершение с `submit_plan` от последнего состояния.
- Игрок закрыл вкладку → мир остаётся в `status=draft`, доступен в списке миров с пометкой "Не завершён".
### 9.2. Поток редактирования мира (world_editor)
Запускается при `POST /api/worlds/{id}/edit`. Игрок даёт текстовую инструкцию, LLM предлагает изменения, игрок принимает/отклоняет.
```mermaid
sequenceDiagram
autonumber
participant U as Игрок
participant FE as Frontend
participant API as FastAPI
participant WE as WorldEditor
participant LLM as LLM
participant DB as PostgreSQL
U->>FE: Открывает страницу редактирования
FE->>API: GET /api/worlds/{id}
API-->>FE: Полные данные мира
FE->>U: Показывает JSON + чат
U->>FE: Пишет инструкцию "Добавь меч в инвентарь"
FE->>API: POST /api/worlds/{id}/edit {instruction}
API-->>FE: 202 {stream_url}
FE->>API: GET .../editor/stream (SSE)
WE->>LLM: prompt(world_editor) + world_state + instruction
LLM->>WE: tool_call(ask_user, "Какой меч: короткий или длинный?")
WE-->>FE: SSE: clarification
FE->>U: Показывает вопрос
U->>FE: Отвечает "Короткий"
FE->>API: POST .../answer {text: "Короткий"}
API->>WE: deliver answer
WE->>LLM: tool_result("Короткий")
LLM->>WE: tool_call(entity_create, item "Короткий меч")
WE->>DB: (в staging-транзакции) INSERT entity
LLM->>WE: tool_call(propose_changes, {diff, comment})
WE-->>FE: SSE: change_proposed + comment
U->>FE: Принимает изменения
FE->>API: POST .../apply
API->>WE: commit staging
WE->>DB: COMMIT
WE-->>FE: SSE: apply_changes + done
U->>FE: Нажимает "Сохранить"
FE->>API: PATCH /api/worlds/{id} (если нужны ещё правки)
```
**Edge-cases:**
- Игрок отклонил изменения → staging-транзакция rollback, LLM получает `tool_result(discard)` и может предложить альтернативу.
- Игрок дал неоднозначную инструкцию → LLM использует `ask_user` для уточнения. Если игрок не отвечает 60 секунд → SSE `warning` "Ожидание ответа".
- Игрок вручную правит JSON параллельно с LLM-итерацией → optimistic lock через `updated_at`: при PATCH сервер проверяет, что `updated_at` не изменился; если изменился — `409 state_conflict`.
### 9.3. Поток итерации сессии (orchestrator)
**Самый сложный поток.** Запускается при `POST /api/sessions/worlds/{id}/iterate`. Три фазы + подфазы для deferred triggers и summary.
#### 9.3.1. Общая sequence-диаграмма
```mermaid
sequenceDiagram
autonumber
participant U as Игрок
participant FE as Frontend
participant API as FastAPI
participant ORC as Orchestrator
participant LLM as LLM
participant TOOLS as ToolRegistry
participant DB as PostgreSQL
participant SUB as Subagent
participant TR as TriggerChecker
U->>FE: Выбирает действие (custom или suggested)
FE->>API: POST /api/sessions/worlds/{id}/iterate {action}
API->>DB: INSERT step (sequence_number, player_action)
API-->>FE: 202 {stream_url, step_id}
FE->>API: GET .../iterate/stream (SSE)
Note over ORC: === Phase 1: Planner + Executor ===
ORC-->>FE: SSE: phase_start {phase:1}
loop До max_substeps или submit_plan
ORC->>LLM: prompt(orchestrator_phase1) + context
LLM->>TOOLS: tool_call(env_update / entity_* / calc / ...)
TOOLS->>DB: mutate state
TOOLS-->>LLM: tool_result
TOOLS-->>FE: SSE: tool_call
LLM-->>ORC: response (continue or submit_plan)
end
ORC-->>FE: SSE: phase_end {phase:1}
Note over ORC: === Phase 2: Writer ===
ORC-->>FE: SSE: phase_start {phase:2}
ORC->>LLM: prompt(orchestrator_phase2) + plan + summary
LLM-->>ORC: tool_call(submit_step, {scene_text, delta_time})
ORC-->>FE: SSE: scene_chunk (streaming)
ORC-->>FE: SSE: scene_complete
ORC-->>FE: SSE: phase_end {phase:2}
Note over ORC: === Phase 3: Persist + Triggers + Summary + Suggest ===
ORC-->>FE: SSE: phase_start {phase:3}
ORC->>DB: COMMIT env + entities + step.scene_text
ORC->>DB: UPDATE world.current_time += delta_time
Note over ORC,TR: 3.1: Deferred triggers
TR->>DB: SELECT * FROM deferred_triggers WHERE fire_at <= current_time AND is_fired=false
loop Для каждого trigger
ORC->>SUB: run_subagent(task, tools)
SUB->>LLM: prompt(subagent) + trigger.payload
LLM->>TOOLS: tool_call(...)
TOOLS->>DB: mutate state
SUB->>LLM: prompt(subagent_summary)
LLM-->>SUB: short_text
SUB-->>ORC: {offscreen_summary, state_changes}
ORC-->>FE: SSE: trigger_fired
ORC->>DB: UPDATE trigger SET is_fired=true, fired_at=now()
end
Note over ORC: 3.2: Summary (если нужно)
ORC->>DB: SELECT count(*) FROM steps WHERE world_id=...
alt count > compression_threshold
ORC->>LLM: prompt(summary) + old_messages
LLM-->>ORC: summary_text
ORC->>DB: INSERT story_entry (type=event, content=summary_text)
ORC-->>FE: SSE: summary_generated
end
Note over ORC: 3.3: Suggest actions
ORC->>LLM: prompt(suggest_actions) + recent_scene
LLM-->>ORC: tool_call(suggest_actions, {actions: [...]})
ORC->>DB: UPDATE step SET suggested_actions=...
ORC-->>FE: SSE: suggested_actions
ORC-->>FE: SSE: iteration_complete + done
FE->>U: Показывает сцену + новые действия
```
#### 9.3.2. Phase 1 — детально
Phase 1 — циклическая. У неё есть лимит `game.max_substeps_per_iteration` (default 8). Каждый цикл:
1. ORC собирает контекст: system prompt + environment + recent_steps (с учётом summary) + player_action.
2. LLM получает контекст и массив доступных tools (game tools + `submit_plan`).
3. LLM может вызвать несколько tools в одном response (parallel tool calls).
4. ORC исполняет tools, эмитит SSE `tool_call`, добавляет `tool_result` в messages.
5. LLM продолжает, пока не вызовет `submit_plan` или не превысит лимит.
**Параметры LLM:** `temperature=0.7`, `top_p=0.9`, `max_tokens=2048`.
**Edge-cases Phase 1:**
- LLM вызвала tool, который вернул `ok=false` → LLM получает `tool_result` с ошибкой, может исправиться.
- LLM вызвала unknown tool → ORC возвращает `tool_result(ok=false, code=unknown_tool)`.
- LLM не вызвала `submit_plan` за лимит → ORC форсит завершение: собирает summary из последних tool_results, формирует `submit_plan` автоматически.
- LLM вернула `finish_reason=length` (уперлась в `max_tokens`) → ORC увеличивает `max_tokens` до 4096 и ретраит. Если снова length — форсит завершение.
#### 9.3.3. Phase 2 — детально
Phase 2 — один LLM-вызов с одним tool `submit_step`.
1. ORC собирает компактный контекст: system prompt (writer) + plan + summary из Phase 1 + environment (snapshot).
2. LLM вызывает `submit_step(scene_text, delta_time)`.
3. ORC стримит `scene_text` во фронтенд через SSE `scene_chunk`.
4. После завершения — `scene_complete` с полным текстом и `delta_time`.
**Параметры LLM:** `temperature=0.85`, `top_p=0.95`, `max_tokens=2048`.
**Edge-cases Phase 2:**
- LLM не вызвала `submit_step` → ORC ретраит с явным указанием "MUST call submit_step". После 2 ретраев — `error` с кодом `writer_no_submit`.
- `scene_text` < 100 символов → ORC отклоняет, просит LLM расписать подробнее.
- `delta_time` не парсится → ORC использует дефолт `hours_1`.
#### 9.3.4. Phase 3 — детально
Phase 3 — детерминированная (без LLM, кроме подфаз 3.1/3.2/3.3).
**3.0 Persist:**
- COMMIT основной транзакции: `environment`, `entities`, `step.scene_text`, `step.scene_delta_time`, `step.tool_calls_summary`.
- `world.current_time = advance_world_time(world.current_time, delta_time, world.time_schema)`.
**3.1 Deferred triggers (если `game.deferred_triggers_enabled=true`):**
```python
triggers = await db.execute(
select(DeferredTrigger)
.where(
DeferredTrigger.world_id == world_id,
DeferredTrigger.is_fired == False,
DeferredTrigger.fire_at <= world.current_time,
)
)
for trigger in triggers:
subagent_result = await run_subagent(
task=f"Process deferred trigger: {trigger.event_type}",
context=trigger.payload,
tools=["entity_create", "entity_update", "env_update", "rag_add"],
max_iterations=5,
)
# subagent_result.offscreen_summary добавляется к scene_text
step.scene_text += "\n\n" + subagent_result.offscreen_summary
trigger.is_fired = True
trigger.fired_at = now()
await db.commit()
```
**3.2 Summary (если нужно):**
```python
recent_steps = await get_recent_steps(world_id, limit=compression_threshold + 1)
if len(recent_steps) > compression_threshold:
old_messages = [s.to_llm_message() for s in recent_steps[guaranteed:]]
summary = await llm.complete(
prompt=get_prompt("summary", world.language),
messages=old_messages,
temperature=0.3,
)
await db.insert(StoryEntry(
world_id=world_id,
content=summary,
entry_type="event",
metadata={"type": "summary", "step_range": [first_seq, last_seq]},
))
```
**3.3 Suggest actions:**
```python
suggestions = await llm.complete(
prompt=get_prompt("suggest_actions", world.language),
messages=[recent_scene_message],
tools=[suggest_actions_tool],
temperature=0.8,
)
step.suggested_actions = suggestions.actions
```
### 9.4. Поток инициализации сервера
Запускается при старте приложения (`lifespan` handler в FastAPI).
```mermaid
sequenceDiagram
participant Uvicorn
participant App as FastAPI
participant DB as PostgreSQL
participant Settings as settings_service
Uvicorn->>App: startup
App->>DB: CREATE EXTENSION IF NOT EXISTS vector
App->>DB: alembic upgrade head
App->>Settings: load_defaults_from_env()
Settings->>DB: UPSERT settings FROM .env
App->>Settings: ensure_admin_setup_token()
Settings-->>App: token (from DB or generated)
App->>App: print("Admin setup URL: /register/admin?token=...")
App->>DB: SELECT COUNT(*) FROM users WHERE is_admin=true
alt no admins
App->>App: print("WARN: no admins yet, use setup URL")
end
```
### 9.5. Поток создания администратора
При каждом запуске сервер выводит в лог URL вида `/register/admin?token=`. Токен берётся из `.env` (если задан) или генерируется случайный и сохраняется в `settings`.
```mermaid
sequenceDiagram
participant Admin as Будущий админ
participant FE as Frontend
participant API as FastAPI
Admin->>FE: Открывает /register/admin?token=XXX
FE->>API: POST /api/register/admin {token, email, username, password}
API->>API: validate token == settings['admin.setup_token']
alt token valid
API->>API: create user (is_admin=true)
API-->>FE: 201 {user}
FE->>Admin: Редирект на /login
else token invalid
API-->>FE: 403 invalid_admin_token
end
```
**Edge-cases:**
- Если в БД уже есть админ → регистрация по admin-URL блокируется (403 `admin_already_exists`), даже с верным токеном.
- Токен ротируется каждые 24 часа (cron task).
### 9.6. Регистрация пользователя
Обычная регистрация. Доступна только если в БД уже есть хотя бы один админ (иначе система "закрыта" до создания первого админа).
**Валидации:**
- `email`: RFC-совместимый, не длиннее 255 символов, уникальный.
- `username`: 3-64 символа, `[a-zA-Z0-9_]`, уникальный.
- `password`: ≥ 8 символов, минимум 1 буква и 1 цифра, не в blacklist (top-1000 утечек).
- `password_confirm`: должен совпадать с `password`.
### 9.7. Вход в систему
Логин по `email` ИЛИ `username` (автоопределение по наличию `@`). JWT-токен с `expires_in=86400` (24 часа). Refresh-токен с `expires_in=604800` (7 дней).
```mermaid
sequenceDiagram
participant U as Пользователь
participant FE as Frontend
participant API as FastAPI
participant DB as PostgreSQL
U->>FE: Вводит login + password
FE->>API: POST /api/auth/login {login, password}
API->>DB: SELECT user WHERE email=$1 OR username=$1
alt user found
API->>API: verify bcrypt(password, user.password_hash)
alt password correct
API->>API: generate JWT (sub=user_id, exp=now+24h)
API->>DB: UPDATE user.last_login_at=now()
API-->>FE: 200 {access_token, refresh_token, user}
FE->>FE: store token in authStore
FE->>U: redirect to /worlds
else password wrong
API-->>FE: 401 invalid_credentials
end
else user not found
API-->>FE: 401 invalid_credentials
end
```
**Anti-enumeration:** ответ на "неверный пароль" и "пользователь не найден" одинаковый (`401 invalid_credentials`), чтобы не давать информации для перебора.
---
## 10. Промпт-шаблоны и контекстный менеджер
### 10.1. Структура промптов
Все системные промпты лежат в `app/prompts/` как Python-строки с `str.format()` интерполяцией. Единственная точка доступа — функция `get_prompt(stage, language)`:
```python
# app/prompts/__init__.py
from app.prompts.registry import get_prompt
system_prompt = get_prompt("orchestrator_phase1", "ru").format(
world_name=world.name,
rules="\n".join(f"- {r}" for r in world.rules),
schemas_summary=summarize_schemas(world.schemas),
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
current_time=world.current_time,
plot_rails_json=json.dumps(world.plot_rails, ensure_ascii=False, indent=2),
)
```
**Структура директории:**
```
app/prompts/
├── __init__.py
├── registry.py # get_prompt(stage, language)
├── stages/
│ ├── world_builder_schema.py
│ ├── world_builder_env.py
│ ├── world_builder_entities.py
│ ├── world_editor.py
│ ├── orchestrator_phase1.py
│ ├── orchestrator_phase2.py
│ ├── orchestrator_phase3_summary.py
│ ├── orchestrator_phase3_suggest.py
│ ├── intro_scene.py
│ ├── subagent.py
│ └── summary.py
└── locales/
├── en.py
└── ru.py
```
**Соглашение:** каждый файл `stages/*.py` экспортирует dict `PROMPTS = {"en": "...", "ru": "..."}`. `registry.py` собирает их в один большой dict по ключу `stage`.
> **⚠️ Языковой правило для LLM-промптов (критично):** Все промпты и инструкции для LLM (system-сообщения, описания инструментов в JSON-schema, runtime-инструкции, сводные блоки типа `rules` / `schemas_summary` / `environment_json`) **должны быть на английском** — даже если `world.language = 'ru'` и игровой нарратив генерируется на русском. Английские промпты дают существенно более высокое качество для современных LLM (особенно локальных 7B-32B моделей): меньше галлюцинаций, точнее tool-calling, лучше следование формату.
>
> **Разделение:**
> - **Промпт (внутренний)** — английский. Хранится в `app/prompts/stages/*.py` под ключом `"en"`. Ключ `"ru"` в PROMPTS оставлен только для legacy-сценариев и в новой разработке НЕ используется.
> - **Нарратив (внешний, для игрока)** — язык мира `world.language`. Phase 2 writer инструктируется английским промптом, но генерирует текст на языке мира (`«Write the scene in {language}»`).
> - **UI-строки фронтенда** — язык интерфейса пользователя (`uiStore.language`), через i18n.
>
> Это правило обязательно для всех stage-промптов. Если в существующем промпте есть русский — это баг, нужно перевести.
### 10.2. Шаблон system-промпта для orchestrator_phase1
```python
# app/prompts/stages/orchestrator_phase1.py
PROMPTS = {
"ru": """Ты — Game Master (GM) текстовой ролевой игры в мире "{world_name}". # LEGACY: не используется, см. языковое правило §10.1
...
""",
"en": """You are the Game Master (GM) of a text RPG in the world "{world_name}".
# Your responsibilities
1. Evaluate the player's action and decide what happened mechanically.
2. Call tools for ANY state change in the world.
3. Do NOT write free narrative — the writer will do that in Phase 2.
4. End Phase 1 by calling submit_plan with the plan and action summary.
# World rules
{rules}
# Entity schemas
{schemas_summary}
# Current environment
{environment_json}
# Plot rails
{plot_rails_json}
# Current time
{current_time}
# Available tools
You can call: entity_create, entity_get, entity_list, entity_update, entity_delete,
env_update, env_get, rag_query, rag_add, schedule_trigger, advance_time, calc, random_choice,
run_subagent, update_plot_rails, submit_plan.
# Hard rules
- ANY state change goes through a tool call. Do NOT write "you took damage" in the text.
- After each tool call you receive a tool_result. Check ok=true.
- If ok=false — fix the arguments and try again.
- Use calc for dice rolls and arithmetic. Do NOT compute in your head.
- After max {max_substeps} steps you MUST call submit_plan.
"""
}
```
### 10.3. Контекстный менеджер истории
LLM-контекст ограничен (8K–32K токенов в зависимости от модели). В долгих сессиях нельзя передавать всю историю. Стратегия: последние N сообщений + опциональный summary.
**Логика (реализована в `app/engine/context.py`):**
```python
async def build_context(
world: World,
step: Step,
recent_steps: list[Step],
settings: Settings,
) -> list[dict]:
"""
Возвращает массив messages для LLM.
"""
guaranteed = settings["context.guaranteed_messages"] # default 10
threshold = settings["context.compression_threshold_messages"] # default 20
messages: list[dict] = []
# 1. System prompt
messages.append({"role": "system", "content": system_prompt})
# 2. Если история длинная — добавляем summary в начале
if len(recent_steps) > threshold:
summary = await get_latest_summary(world.id)
if summary:
messages.append({
"role": "system",
"content": f"Сводка прошлых событий:\n{summary.content}"
})
# Берём только последние guaranteed шагей
recent_steps = recent_steps[-guaranteed:]
# 3. Последние шаги как user/assistant messages
for s in recent_steps:
messages.append({"role": "user", "content": s.player_action})
messages.append({"role": "assistant", "content": s.scene_text})
# 4. Текущее действие игрока
messages.append({"role": "user", "content": step.player_action})
return messages
```
**Когда генерируется summary:**
- При каждом Phase 3 orchestrator проверяет: `len(recent_steps) > threshold` (с учётом уже существующих summaries).
- Если да — вызывает LLM с `prompt(summary)` на сообщения `[guaranteed:last]`.
- Создаёт `StoryEntry` с `entry_type="event"`, `metadata={"type": "summary", "step_range": [...]}`.
- Старые сообщения (кроме guaranteed) **не удаляются** из БД — они просто не попадают в контекст LLM. Полная история доступна через API.
**Параметры LLM для summary:** `temperature=0.3` (низкая — для точности), `max_tokens=1024`.
### 10.4. Параметры LLM по стадиям
| Stage | temperature | top_p | max_tokens | Stream |
|---|---|---|---|---|
| `world_builder_schema` | 0.5 | 0.9 | 4096 | no |
| `world_builder_env` | 0.6 | 0.9 | 2048 | no |
| `world_builder_entities` | 0.7 | 0.9 | 2048 | no |
| `world_editor` | 0.5 | 0.9 | 2048 | no |
| `orchestrator_phase1` | 0.7 | 0.9 | 2048 | no |
| `orchestrator_phase2` | 0.85 | 0.95 | 2048 | yes (scene_chunk) |
| `orchestrator_phase3_summary` | 0.3 | 0.9 | 1024 | no |
| `orchestrator_phase3_suggest` | 0.8 | 0.95 | 512 | no |
| `intro_scene` | 0.85 | 0.95 | 2048 | yes |
| `subagent` | 0.6 | 0.9 | 2048 | no |
| `summary` | 0.3 | 0.9 | 1024 | no |
---
## 11. RAG-подсистема и валидация состояния
### 11.1. RAG через Qdrant
Архитектура RAG-подсистемы построена на **разделении хранения**: PostgreSQL хранит текст и метаданные, Qdrant — только векторы. Это даёт несколько преимуществ:
- **Масштабируемость:** Qdrant оптимизирован для ANN-поиска (HNSW) и не нагружает PostgreSQL векторными индексами.
- **Независимость:** можно менять embedding-модель без миграции реляционной схемы — достаточно пересоздать коллекцию и переиндексировать.
- **Изоляция миров:** каждая точка в Qdrant имеет payload `{world_id, entity_type, deleted_at, ...}`; фильтр по `world_id` гарантирует, что поиск в одном мире никогда не вернёт результаты другого.
#### 11.1.1. Топология коллекций
Две коллекции в одном Qdrant-инстансе (НЕ одна коллекция на мир — это усложнило бы администрирование):
| Коллекция | Размерность | Distance | Payload-поля | Назначение |
|---|---|---|---|---|
| `entities` | из `settings.embeddings.dimension` | `Cosine` | `world_id`, `entity_id`, `entity_type`, `name`, `deleted` | Векторы сущностей (character, item, location, ...) |
| `story_entries` | из `settings.embeddings.dimension` | `Cosine` | `world_id`, `entry_id`, `entry_type`, `step_id`, `created_at` | Векторы сюжетных записей (fact, event, summary, ...) |
ID точки в Qdrant = `str(uuid)` соответствующей записи в PostgreSQL. В PostgreSQL колонка `qdrant_point_id` хранит тот же UUID — это позволяет восстановить связь при переиндексации.
> **Почему одна коллекция на тип, а не на мир?** Создание коллекции под каждый мир потребует N коллекций (по числу миров), что усложнит администрирование и резервное копирование. Фильтр по `world_id` в payload даёт ту же изоляцию с минимальным оверхедом — Qdrant индексирует payload-поля отдельно от векторов (payload index), поэтому фильтрация по `world_id` практически бесплатна.
#### 11.1.2. Создание коллекций (startup-хук)
`app/migrations/init_qdrant.py` запускается при старте приложения (после `alembic upgrade head`). Создаёт коллекции если их нет, и обязательные payload-индексы для быстрых фильтров:
```python
# app/migrations/init_qdrant.py
from qdrant_client import AsyncQdrantClient
from qdrant_client.http.models import Distance, VectorParams, PayloadSchemaType
async def init_qdrant_collections(dimension: int) -> None:
client = AsyncQdrantClient(url=settings.QDRANT_URL, api_key=settings.QDRANT_API_KEY)
existing = {c.name for c in (await client.get_collections()).collections}
for name in ("entities", "story_entries"):
if name in existing:
continue
await client.create_collection(
collection_name=name,
vectors_config=VectorParams(size=dimension, distance=Distance.COSINE),
)
await client.create_payload_index(name, "world_id", PayloadSchemaType.KEYWORD)
if name == "entities":
await client.create_payload_index(name, "entity_type", PayloadSchemaType.KEYWORD)
await client.create_payload_index(name, "deleted", PayloadSchemaType.BOOL)
else:
await client.create_payload_index(name, "entry_type", PayloadSchemaType.KEYWORD)
await client.create_payload_index(name, "created_at", PayloadSchemaType.INTEGER)
```
#### 11.1.3. Интерфейс Embedder
```python
# app/core/embeddings.py
from typing import Protocol
import httpx
class Embedder(Protocol):
async def embed(self, texts: list[str]) -> list[list[float]]: ...
@property
def dimension(self) -> int: ...
class HashEmbedder:
"""Offline-эмбеддер для dev/test. Bag-of-words + hash projection."""
def __init__(self, dimension: int = 256):
self._dim = dimension
async def embed(self, texts: list[str]) -> list[list[float]]:
return [self._hash_project(t) for t in texts]
@property
def dimension(self) -> int:
return self._dim
class OpenAIEmbedder:
"""OpenAI-compatible embeddings API."""
def __init__(self, api_url: str, api_key: str, model: str, dimension: int, timeout: float = 30.0):
self._api_url = api_url.rstrip("/")
self._api_key = api_key
self._model = model
self._dim = dimension
self._timeout = timeout
async def embed(self, texts: list[str]) -> list[list[float]]:
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(
f"{self._api_url}/embeddings",
headers={"Authorization": f"Bearer {self._api_key}"},
json={"model": self._model, "input": texts},
)
resp.raise_for_status()
data = resp.json()
return [d["embedding"] for d in sorted(data["data"], key=lambda x: x["index"])]
@property
def dimension(self) -> int:
return self._dim
```
#### 11.1.4. Реализация rag_query (двухстадийный retrieval)
Сначала ищем в Qdrant → получаем IDs + scores → затем по IDs подтягиваем полные данные из PostgreSQL. Это держит payload Qdrant маленьким, а полный текст — в реляционной БД.
```python
# app/core/rag.py
from uuid import UUID
from qdrant_client import AsyncQdrantClient
from qdrant_client.http.models import Filter, FieldCondition, MatchValue
async def rag_query(
world_id: UUID,
query: str,
limit: int = 5,
filter_type: str = "all",
min_score: float = 0.7,
) -> list[dict]:
"""Семантический поиск по entities + story_entries через Qdrant."""
embedder = get_embedder()
try:
query_vec = (await embedder.embed([query]))[0]
except Exception as e:
logger.warning("rag_query_embed_failed", error=str(e))
return [] # embedder недоступен — возвращаем пусто, не роняем итерацию
qdrant: AsyncQdrantClient = get_qdrant_client()
world_filter = FieldCondition(key="world_id", match=MatchValue(value=str(world_id)))
results: list[dict] = []
if filter_type in ("all", "entities"):
ents = await qdrant.search(
collection_name="entities",
query_vector=query_vec,
query_filter=Filter(must=[world_filter,
FieldCondition(key="deleted", match=MatchValue(value=False))]),
limit=limit,
score_threshold=min_score,
with_payload=True,
)
for p in ents:
results.append({"type": "entity", "id": p.payload["entity_id"],
"score": p.score, "name": p.payload.get("name"),
"entity_type": p.payload.get("entity_type")})
if filter_type in ("all", "story_entries"):
sts = await qdrant.search(
collection_name="story_entries",
query_vector=query_vec,
query_filter=Filter(must=[world_filter]),
limit=limit,
score_threshold=min_score,
with_payload=True,
)
for p in sts:
results.append({"type": "story_entry", "id": p.payload["entry_id"],
"score": p.score, "entry_type": p.payload.get("entry_type")})
# Sort by score desc, truncate
results.sort(key=lambda r: r["score"], reverse=True)
top = results[:limit]
# Stage 2: fetch full data from PostgreSQL by IDs
return await _hydrate_from_postgres(top, world_id)
async def _hydrate_from_postgres(items: list[dict], world_id: UUID) -> list[dict]:
"""Подтягивает полные данные entities.data и story_entries.content из PostgreSQL."""
entity_ids = [UUID(i["id"]) for i in items if i["type"] == "entity"]
story_ids = [UUID(i["id"]) for i in items if i["type"] == "story_entry"]
entities_map: dict[UUID, dict] = {}
stories_map: dict[UUID, dict] = {}
if entity_ids:
rows = await db.execute(select(Entity).where(Entity.id.in_(entity_ids)))
entities_map = {r.id: {"name": r.name, "entity_type": r.entity_type, "data": r.data} for r in rows.scalars()}
if story_ids:
rows = await db.execute(select(StoryEntry).where(StoryEntry.id.in_(story_ids)))
stories_map = {r.id: {"content": r.content, "entry_type": r.entry_type, "metadata": r.metadata} for r in rows.scalars()}
out = []
for i in items:
if i["type"] == "entity":
full = entities_map.get(UUID(i["id"]))
if full:
out.append({**i, "content": full})
else:
full = stories_map.get(UUID(i["id"]))
if full:
out.append({**i, "content": full})
return out
```
#### 11.1.5. Реализация rag_add
```python
async def rag_add(
world_id: UUID, content: str, entry_type: str, metadata: dict | None = None,
) -> dict:
"""Добавляет факт в story_entries + индексирует в Qdrant (синхронно)."""
entry = StoryEntry(world_id=world_id, content=content, entry_type=entry_type,
metadata=metadata or {}, embedding_status="pending")
db.add(entry)
await db.flush() # получаем entry.id
try:
vec = (await get_embedder().embed([content[:4000]]))[0]
point_id = str(entry.id)
await get_qdrant_client().upsert(
collection_name="story_entries",
points=[PointStruct(id=point_id, vector=vec,
payload={"world_id": str(world_id), "entry_id": point_id,
"entry_type": entry_type,
"step_id": str(metadata.get("step_id")) if metadata else None,
"created_at": int(entry.created_at.timestamp())})]
)
entry.qdrant_point_id = point_id
entry.embedding_status = "indexed"
except Exception as e:
logger.warning("rag_add_embed_failed", error=str(e), entry_id=str(entry.id))
entry.embedding_status = "failed"
# Фоновой индексатор попробует снова
await db.commit()
return {"id": str(entry.id), "status": entry.embedding_status}
```
#### 11.1.6. Удаление мира и Qdrant-очистка
При `DELETE /api/worlds/{id}` (cascade delete в PostgreSQL) срабатывает хук `app/api/worlds.py::_cleanup_qdrant(world_id)`:
```python
async def _cleanup_qdrant(world_id: UUID) -> None:
"""Best-effort удаление точек мира из Qdrant. Ошибки логируем, но не падаем."""
client = get_qdrant_client()
for collection in ("entities", "story_entries"):
try:
await client.delete(
collection_name=collection,
points_selector=FilterSelector(
filter=Filter(must=[FieldCondition(key="world_id",
match=MatchValue(value=str(world_id)))]))
)
except Exception as e:
logger.error("qdrant_cleanup_failed", collection=collection,
world_id=str(world_id), error=str(e))
```
Аналогично при soft-delete `entity.deleted_at = now()` — точка в Qdrant помечается `deleted: true` в payload (не удаляется физически, чтобы можно было восстановить; сборщик мусора раз в сутки удаляет `deleted: true` старше 7 дней).
### 11.2. Валидатор состояния
`app/core/state_validator.py` — критический модуль. Любое изменение `environment` или `entity.data` проходит через него.
**Сигнатуры:**
```python
def validate_state(state: dict, schema: dict) -> tuple[bool, list[str]]:
"""
Возвращает (ok, errors). errors — список человекочитаемых строк.
Проверяет:
- Все обязательные поля присутствуют.
- Типы полей соответствуют объявленным.
- Значения в диапазонах (для integer с max).
- Структура object/array соответствует вложенной schema.
"""
def apply_patch(state: dict, patch: dict) -> tuple[dict, list[str]]:
"""
Применяет JSON-patch к state и возвращает (new_state, errors).
patch: {field_path: new_value} или {field_path: {op: "inc", by: N}}.
Поддерживаемые op: 'set', 'inc', 'dec', 'append', 'remove'.
Возвращает new_state только если errors пуст.
"""
def validate_world(world: World) -> tuple[bool, list[str]]:
"""
Полная валидация мира: schemas, environment_schema, environment, current_time.
Используется при создании/редактировании мира.
"""
```
**Что валидатор проверяет обязательно:**
| Проверка | Уровень | Поведение при ошибке |
|---|---|---|
| Все обязательные поля `environment_schema` присутствуют в `environment` | syntax | `errors.append("Missing required field: player")` |
| `player` соответствует `schema character` (есть name, stats, stats.health — целое, etc.) | syntax | `errors.append("player.stats.health must be integer")` |
| `current_location` — непустая строка | syntax | `errors.append("current_location must be non-empty")` |
| `current_location` ссылается на существующую `Entity` типа `location` | semantic (soft) | warning, можно отключить в `settings` |
| `plot_rails` содержит `hooks` (list) и `current_goals` (list) | syntax | `errors.append("plot_rails.hooks must be list")` |
| `current_time` парсится как `day_D_hour_H[_min_M]` | syntax | `errors.append("Invalid current_time format")` |
| `hour < hours_in_day` из `time_schema` | semantic | `errors.append("hour 25 exceeds hours_in_day 24")` |
| Все типы полей соответствуют объявленным в `schema` (integer — int, boolean — bool, etc.) | syntax | `errors.append("Field X must be integer, got string")` |
| Для `integer` с `max`: значение ≤ max | semantic | `errors.append("health 150 exceeds max 100")` |
| Для `array` с `max`: длина ≤ max | semantic | `errors.append("inventory has 20 items, max 10")` |
**Поток валидации при `env_update`:**
```python
async def execute_env_update(patch: dict, ctx: ToolContext) -> ToolResult:
new_env, errors = apply_patch(ctx.world.environment, patch)
if errors:
return ToolResult(ok=False, error={"code": "validation_error", "message": "; ".join(errors)})
ok, errors = validate_state(new_env, ctx.world.environment_schema)
if not ok:
return ToolResult(ok=False, error={"code": "validation_error", "message": "; ".join(errors)})
# Дополнительная семантическая валидация
ok, errors = validate_world_state_semantic(new_env, ctx.world)
if not ok:
return ToolResult(ok=False, error={"code": "semantic_error", "message": "; ".join(errors)})
ctx.world.environment = new_env
await ctx.session.commit()
return ToolResult(ok=True, data={"applied_paths": list(patch.keys())}, message="Environment updated")
```
### 11.3. JSON-patch формат
Поддерживаемые операции в `patch`:
| Формат значения | Операция | Пример |
|---|---|---|
| `{"field": value}` | `set` (заменить) | `{"player.stats.health": 50}` |
| `{"field": {"op": "inc", "by": N}}` | `inc` (прибавить) | `{"player.stats.health": {"op": "inc", "by": -10}}` |
| `{"field": {"op": "dec", "by": N}}` | `dec` (вычесть) | `{"player.stats.mana": {"op": "dec", "by": 5}}` |
| `{"field": {"op": "append", "value": X}}` | `append` (добавить в массив) | `{"player.inventory": {"op": "append", "value": {"item_id": "sword", "qty": 1}}}` |
| `{"field": {"op": "remove", "index": N}}` | `remove` (удалить из массива по индексу) | `{"player.inventory": {"op": "remove", "index": 2}}` |
`field_path` поддерживает точечную нотацию: `player.stats.health`, `plot_rails.current_goals[0]`, `schemas[1].properties[2].verbose`.
### 11.4. Управление игровым временем
`app/core/time_utils.py`:
```python
def parse_time(s: str) -> dict:
"""Парсит '[year_Y_]day_D_hour_H[_min_M]' в dict {year, day, hour, min}."""
def format_time(t: dict) -> str:
"""Сериализует dict в строку."""
def advance_time(current: str, delta: str, time_schema: dict) -> str:
"""
Прибавляет delta к current, учитывая hours_in_day.
Пример: advance_time('day_1_hour_23', 'hours_2', {hours_in_day: 24}) -> 'day_2_hour_1'.
"""
def compare_time(a: str, b: str) -> int:
"""-1 если a < b, 0 если a == b, 1 если a > b. Для deferred_triggers."""
```
### 11.5. Контекстная оптимизация (Context window management)
LLM-контекст ограничен (8K–32K токенов для локальных моделей, до 128K для крупных cloud-моделей). Контекстный менеджер `app/engine/context.py` отвечает за то, чтобы каждый вызов LLM получал максимально информативный промпт, не превышающий бюджет. Это критически важно для долгих сессий: без сжатия контекст быстро переполняется, модель начинает «забывать» ранние события и галлюцинировать.
#### 11.5.1. Бюджет токенов
Каждый промпт делится на сегменты с фиксированными и динамическими бюджетами:
| Сегмент | Типичный объём (токенов) | Управление |
|---|---|---|
| System prompt (роль + правила + схемы + environment) | 1500–3000 | Статичный шаблон + interpolated из `world.*` |
| Summary (если есть) | 300–800 | Генерируется в Phase 3, кешируется в `story_entries` |
| RAG-retrieved facts (если LLM вызывала `rag_query`) | 0–1500 | Динамически, по результатам tool call |
| Recent messages (guaranteed) | 1500–4000 | Последние N шагов (default 10) |
| Current action | 50–300 | Текущее действие игрока |
| Reserved for completion | 1024–4096 | `max_tokens` из `settings.llm.max_tokens` |
**Формула бюджета:**
```
total = system + summary + rag + recent + action + reserved
total <= model_context_window - safety_margin (default 500 токенов)
```
Если бюджет превышен, контекстный менеджер поочерёдно применяет деградацию:
1. Уменьшает `recent` — отбрасывает самые старые из guaranteed, но не ниже 4 последних шагей.
2. Уменьшает `rag` — отбрасывает самые низко-скоринговые факты.
3. Урезает `summary` до 200 токенов (берёт первое предложение + ключевые имена).
4. Если всё ещё превышено — форсирует генерацию нового summary для большего диапазона шагов и повторяет цикл.
Если после всех шагов промпт всё ещё не помещается — возвращается ошибка `context_overflow`, итерация помечается `failed`, оператору показывается warning «пора увеличить context window модели или уменьшить guaranteed_messages».
#### 11.5.2. Трёхуровневая стратегия контекста
Контекст строится из трёх уровней, каждый со своей политикой устаревания:
1. **Environment (всегда в контексте).** JSON-блок `player` + `current_location` + `plot_rails` + кастомные поля. Не требует tool call — LLM видит его сразу в system-промпте. Это «рабочая память» GM. Оптимальный размер — 1500–2500 токенов; если превышает, контекстный менеджер логирует warning и предлагает оператору упростить `environment_schema`.
2. **Recent messages (guaranteed).** Последние N шагов (default 10, настраивается в `settings.context.guaranteed_messages`). Каждый шаг = `user: action` + `assistant: scene_text`. Если шаги длинные, контекстный менеджер обрезает `scene_text` до 500 токенов, сохраняя начало (200) и конец (300) — так сохраняются и вступление сцены, и финальное действие.
3. **Summary (compressed history).** Когда `len(recent_steps) > settings.context.compression_threshold_messages` (default 20) или `token_count(recent) > compression_threshold_tokens` (default 6000), Phase 3 генерирует summary для отброшенных шагов. Summary хранится как `StoryEntry` с `entry_type='summary'` и `metadata={"step_range": [from_seq, to_seq]}`. При следующей итерации summary подставляется в контекст как `system`-message: `«Сводка прошлых событий (шаги 5-18): ...»`. Несколько summary могут сосуществовать, если сессия очень длинная — контекстный менеджер берёт самое свежее.
#### 11.5.3. RAG-стратегия: когда подтягивать факты
RAG-результаты **не добавляются в контекст автоматически** — это инструменты, LLM вызывает их сама когда считает нужным. Но контекстный менеджер даёт подсказку в system-промпте (на английском для лучшего качества — см. §10.1):
> "If you need to recall details from the past (NPC names, world facts, past events), call `rag_query` with a descriptive query. Do NOT try to recall details from memory — you may hallucinate."
**Принудительный RAG-call** (опционально, по настройке `context.auto_rag_on_entity_mention`, default `false`): если в `player_action` упоминается сущность по имени (простой regex-match по `entities.name`), контекстный менеджер делает `rag_query` автоматически и добавляет результаты как `system`-message: `«Relevant facts retrieved: ...»`. Это уменьшает количество tool calls и latency, но может подтянуть нерелевантный шум. По умолчанию **отключено** — оставляем решение за LLM.
#### 11.5.4. Pre-filtering и изоляция миров
Qdrant-фильтр `world_id == ` обязателен для каждого `rag_query`. Это гарантирует, что:
- Сущности и факты мира A никогда не попадут в контекст игры в мире B.
- Удаление мира (`DELETE /api/worlds/{id}`) каскадно удаляет точки в Qdrant (см. §11.1.6).
- Бэкап и восстановление мира можно делать независимо от других миров (см. §13.6).
#### 11.5.5. Гибридный поиск (опционально, future)
В будущих спринтах (post-MVP) можно добавить гибридный поиск: BM25 (через PostgreSQL `tsvector` + GIN-индекс на `entities.name`, `story_entries.content`) + dense (Qdrant). RRF (Reciprocal Rank Fusion) объединяет ранжирования. Это улучшает поиск для коротких точных запросов (имена NPC, названия локаций). В текущем ТЗ **не обязательно**, но архитектура (двухстадийный retrieval из §11.1.4) это позволяет без переписывания.
#### 11.5.6. Кеширование
- **Embedding cache:** `app/core/cache.py::EmbeddingCache` — LRU на 1000 запросов, TTL 5 минут. Ключ = `sha256(text)[:16]`. Хранит только векторы запросов (`rag_query`); векторы документов живут в Qdrant.
- **Summary cache:** один summary на мир на step_range; инвалидируется при откате шагов (см. `DELETE /api/steps/{id}` rollback).
- **Prompt cache:** system-промпт кешируется после первой сборки для мира; инвалидируется при `world.schemas` / `world.environment_schema` изменениях (через dirty-flag в `worlds.updated_at`).
#### 11.5.7. Оценка токенов
```python
# app/core/tokens.py
def estimate_tokens(text: str, model: str) -> int:
"""Приближённая оценка token count.
Для OpenAI-моделей — tiktoken; для остальных — len(text)/4."""
try:
import tiktoken
enc = tiktoken.encoding_for_model(model)
return len(enc.encode(text))
except Exception:
return max(1, len(text) // 4)
```
Используется в контекстном менеджере для всех сегментов. System prompt оценивается один раз при первой сборке и кешируется (его размер не меняется в пределах итерации, если не было `env_update`).
#### 11.5.8. Настройки контекста (в `settings.context.*`)
| Ключ | Default | Назначение |
|---|---|---|
| `context.guaranteed_messages` | `10` | Сколько последних шагей всегда в контексте |
| `context.compression_threshold_messages` | `20` | Порог сжатия по числу шагов |
| `context.compression_threshold_tokens` | `6000` | Альтернативный порог по токенам |
| `context.scene_text_truncate_tokens` | `500` | Урезка `scene_text` в recent messages |
| `context.auto_rag_on_entity_mention` | `false` | Авто-вызов `rag_query` при упоминании сущности |
| `context.safety_margin_tokens` | `500` | Резерв от края context window |
### 11.6. Embeddings subsystem
#### 11.6.1. Конфигурация (`settings.embeddings.*`)
| Ключ | Тип | Default | Назначение |
|---|---|---|---|
| `embeddings.provider` | string | `"offline_hash"` | `offline_hash` \| `openai` |
| `embeddings.api_url` | string | `""` | URL OpenAI-compatible endpoint. **Если пусто и provider=`openai` — fallback на `llm.api_url`**. |
| `embeddings.api_key` | string | `""` | API-ключ. **Если пусто и provider=`openai` — fallback на `llm.api_key`**. |
| `embeddings.model` | string | `"text-embedding-3-small"` | Имя модели эмбеддингов |
| `embeddings.dimension` | integer | `1536` | Размерность. **Кнопка «Авто-проба»** в UI вызывает `POST /api/admin/test/embeddings/probe-dimension` и подставляет результат. |
| `embeddings.timeout_seconds` | integer | `30` | Timeout на вызов embeddings API |
| `embeddings.batch_size` | integer | `32` | Размер батча для embedding API |
| `embeddings.cache_ttl_seconds` | integer | `300` | TTL LRU-кеша для query embeddings |
| `embeddings.max_text_chars` | integer | `4000` | Урезка текста перед эмбеддингом (для `story_entries.content`) |
#### 11.6.2. Fallback-правило для OpenAI-провайдера
Если `embeddings.provider == "openai"` и **не заданы** `embeddings.api_url` или `embeddings.api_key`, конструктор `OpenAIEmbedder` берёт значения из `llm.api_url` и `llm.api_key`. Это позволяет единой конфигурацией LLM (`llm.api_url` + `llm.api_key`) закрыть и chat-completions, и embeddings — удобно для self-hosted инстансов (vLLM, ollama, lmdeploy) и для OpenAI-аккаунтов с обоими API.
```python
# app/core/embeddings.py
def build_embedder() -> Embedder:
provider = settings.get("embeddings.provider")
dim = settings.get("embeddings.dimension")
if provider == "offline_hash":
return HashEmbedder(dimension=dim or 256)
if provider == "openai":
# Fallback: если embeddings.api_url/api_key пустые — берём llm.*
api_url = settings.get("embeddings.api_url") or settings.get("llm.api_url")
api_key = settings.get("embeddings.api_key") or settings.get("llm.api_key")
if not api_url or not api_key:
raise ValueError(
"OpenAI embedder requires api_url and api_key "
"(either embeddings.* or llm.* fallback)"
)
model = settings.get("embeddings.model") or "text-embedding-3-small"
timeout = settings.get("embeddings.timeout_seconds") or 30
return OpenAIEmbedder(api_url=api_url, api_key=api_key, model=model,
dimension=dim, timeout=timeout)
raise ValueError(f"Unknown embeddings provider: {provider}")
```
#### 11.6.3. Авто-проба размерности (`embeddings.dimension`)
Размерность вектора **нельзя** угадать по имени модели — `text-embedding-3-small` поддерживает 512/1536/3072, локальные модели на vLLM могут иметь 768/1024/4096. Поэтому в админке есть кнопка **«Авто-проба»** рядом с полем `embeddings.dimension`:
1. UI вызывает `POST /api/admin/test/embeddings/probe-dimension` с телом `{"text": "hello world"}`.
2. Backend дёргает `OpenAIEmbedder.embed(["hello world"])` с текущими настройками.
3. Возвращает `{"ok": true, "dimension": 1536, "model": "text-embedding-3-small", "elapsed_ms": 142}`.
4. UI показывает результат и предлагает кнопку **«Сохранить 1536 в `embeddings.dimension`»**.
Если размерность поменялась — backend проверяет, что новая размерность совместима с существующими Qdrant-коллекциями. Если нет — предлагает пересоздать коллекции (с подтверждением админом) и запускает фоновую переиндексацию всех `entities` и `story_entries` (сбрасывает `embedding_status` в `pending`).
#### 11.6.4. Фоновый индексатор
`app/workers/embedding_indexer.py` — async-воркер, запускается через `asyncio.create_task` в startup-хуке (или через APScheduler в production). Каждые 30 секунд берёт до 50 записей с `embedding_status='pending'`, считает векторы и upsert-ит в Qdrant.
```python
# app/workers/embedding_indexer.py
import asyncio, json
from app.core.embeddings import get_embedder
from app.core.rag import get_qdrant_client
from qdrant_client.http.models import PointStruct
async def embedding_indexer_loop():
while True:
try:
embedder = get_embedder()
qdrant = get_qdrant_client()
dim = embedder.dimension
# Entities
pending = await db.execute(
select(Entity)
.where(Entity.embedding_status == "pending", Entity.deleted_at.is_(None))
.limit(50)
)
for entity in pending.scalars():
text = f"{entity.name}\n{json.dumps(entity.data, ensure_ascii=False)[:2000]}"
try:
vec = (await embedder.embed([text]))[0]
point_id = str(entity.id)
await qdrant.upsert(
collection_name="entities",
points=[PointStruct(id=point_id, vector=vec,
payload={"world_id": str(entity.world_id),
"entity_id": point_id,
"entity_type": entity.entity_type,
"name": entity.name, "deleted": False})]
)
entity.qdrant_point_id = point_id
entity.embedding_status = "indexed"
except Exception as e:
logger.warning("embedding_index_entity_failed",
entity_id=str(entity.id), error=str(e))
entity.embedding_status = "failed"
# StoryEntries — аналогично
# ... (см. полный код в app/workers/embedding_indexer.py)
await db.commit()
except Exception as e:
logger.exception("embedding_indexer_error", error=str(e))
await asyncio.sleep(30)
```
Записи с `embedding_status='failed'` (после 3 ретраев) попадают в лог и доступны для ручного retry через `POST /api/admin/embeddings/retry-failed`.
#### 11.6.5. Текст для эмбеддинга
Чтобы вектор был информативным, текст формируется не только из основного поля:
| Сущность | Формула | Пример |
|---|---|---|
| `Entity` (character) | `name + "\n" + role + "\n" + key_stats + "\n" + description` | `«Sir Galahad\nknight\nHP:80/100, STR:16\nA noble warrior sworn to...»` |
| `Entity` (item) | `name + "\n" + item_type + "\n" + properties` | `«Iron Sword\nweapon\ndamage:1d8+1, weight:3kg»` |
| `Entity` (location) | `name + "\n" + description` | `«Whispering Forest\nA dense wood where...»` |
| `StoryEntry` | `content` (с урезкой до `embeddings.max_text_chars`) | `«Игрок убил дракона в день 3 час 14.»` |
Формула фиксирована в `app/core/embeddings.py::build_text_for_entity(entity)` и `::build_text_for_story_entry(entry)`. Менять формулу = пересоздать коллекцию и переиндексировать.
#### 11.6.6. Тестовые кнопки (см. §6.5 и §12.4)
В админке на странице настроек есть три тестовые кнопки + кнопка авто-пробы:
| Кнопка | Endpoint | Что делает | Что показывает UI |
|---|---|---|---|
| **«Тест LLM»** | `POST /api/admin/test/llm` | Отправляет `"Reply with: OK"` в chat/completions | Ответ модели + latency + token counts |
| **«Тест LLM вызов инструментов»** | `POST /api/admin/test/llm-tools` | Отправляет промпт `"What is 2+2? Use the calc tool."` + 1 инструмент `calc` | Был ли `tool_calls` в ответе, latency, имя вызванного инструмента |
| **«Тест эмбеддинг»** | `POST /api/admin/test/embeddings` | Отправляет `"hello world"` в embeddings API | dimension + первые 5 значений вектора + latency |
| **«Авто-проба размерности»** | `POST /api/admin/test/embeddings/probe-dimension` | То же что «Тест эмбеддинг», но возвращает только dimension | dimension + кнопка «Сохранить в `embeddings.dimension`» |
Все четыре кнопки доступны без сохранения настроек — берут текущие значения из формы (query params `?api_url=&api_key=&model=`), что позволяет тестировать перед сохранением. См. §6.5 для контрактов endpoints.
---
## 12. Фронтенд-архитектура
### 12.1. Структура директорий
```
frontend/
├── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── routes/
│ │ ├── index.tsx # Маршруты (react-router v6)
│ │ ├── ProtectedRoute.tsx
│ │ └── AdminRoute.tsx
│ ├── pages/
│ │ ├── LoginPage.tsx
│ │ ├── RegisterPage.tsx
│ │ ├── RegisterAdminPage.tsx
│ │ ├── WorldsListPage.tsx
│ │ ├── WorldCreatePage.tsx
│ │ ├── WorldEditPage.tsx
│ │ ├── WorldPlayPage.tsx
│ │ └── admin/
│ │ ├── AdminSettingsPage.tsx
│ │ ├── AdminLogsPage.tsx
│ │ └── AdminUsersPage.tsx
│ ├── components/
│ │ ├── ui/ # Базовые компоненты
│ │ │ ├── Button.tsx
│ │ │ ├── Card.tsx
│ │ │ ├── Input.tsx
│ │ │ ├── Modal.tsx
│ │ │ ├── Navbar.tsx
│ │ │ ├── Spinner.tsx
│ │ │ └── cn.ts # classnames утилита
│ │ ├── chat/
│ │ │ ├── ChatWindow.tsx
│ │ │ ├── ChatMessage.tsx
│ │ │ ├── ToolCallBubble.tsx
│ │ │ ├── ActionSelector.tsx
│ │ │ └── ClarificationModal.tsx
│ │ ├── world/
│ │ │ ├── WorldCard.tsx
│ │ │ ├── EnvironmentPanel.tsx
│ │ │ ├── EntityList.tsx
│ │ │ ├── PlotRailsPanel.tsx
│ │ │ └── JsonEditor.tsx
│ │ └── admin/
│ │ ├── SettingsForm.tsx
│ │ ├── LogsTable.tsx
│ │ ├── LogDetailModal.tsx
│ │ ├── ConnectionTests.tsx # блок с 3 тестовыми кнопками + probe-dimension
│ │ ├── TestLlmButton.tsx # «Тест LLM»
│ │ ├── TestLlmToolsButton.tsx # «Тест LLM вызов инструментов»
│ │ ├── TestEmbeddingsButton.tsx # «Тест эмбеддинг»
│ │ ├── ProbeDimensionButton.tsx # «Авто-проба размерности»
│ │ ├── IconUploader.tsx # загрузка favicon/logo через UI
│ │ └── TestResultCard.tsx # карточка с результатом теста
│ ├── stores/ # zustand
│ │ ├── authStore.ts
│ │ ├── uiStore.ts
│ │ ├── worldsStore.ts
│ │ └── sessionStore.ts
│ ├── api/ # HTTP-клиент
│ │ ├── client.ts # fetch wrapper with JWT
│ │ ├── auth.ts
│ │ ├── worlds.ts
│ │ ├── sessions.ts
│ │ ├── admin.ts
│ │ └── sse.ts # EventSource wrapper
│ ├── i18n/
│ │ ├── config.ts
│ │ ├── en.json
│ │ └── ru.json
│ ├── lib/
│ │ ├── utils.ts
│ │ └── constants.ts
│ └── types/
│ ├── api.ts # типы из OpenAPI
│ ├── world.ts
│ └── sse.ts
├── public/
├── index.html
├── vite.config.ts
├── tailwind.config.js
├── tsconfig.json
└── package.json
```
### 12.2. Zustand stores
#### `authStore`
```typescript
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
login: (creds: LoginRequest) => Promise;
logout: () => void;
refresh: () => Promise;
fetchMe: () => Promise;
}
```
Хранит токены в `localStorage`. При истечении access_token — автоматически вызывает `refresh()`.
#### `worldsStore`
```typescript
interface WorldsState {
worlds: WorldSummary[];
currentWorld: World | null;
isLoading: boolean;
error: string | null;
fetchWorlds: () => Promise;
fetchWorld: (id: string) => Promise;
createWorld: (req: CreateWorldRequest) => Promise;
deleteWorld: (id: string) => Promise;
}
```
#### `sessionStore`
```typescript
interface SessionState {
steps: Step[];
environment: Environment | null;
nextActions: string[];
isIterating: boolean;
currentPhase: 1 | 2 | 3 | null;
currentToolCall: ToolCall | null;
sceneStreaming: boolean;
partialScene: string;
sseConnection: EventSource | null;
connectStream: (url: string) => void;
disconnectStream: () => void;
iterate: (action: string, source: 'custom' | 'suggested') => Promise;
retry: () => Promise;
rollback: () => Promise;
answerClarification: (text: string) => Promise;
}
```
#### `uiStore`
```typescript
interface TestResult {
kind: 'llm' | 'llm_tools' | 'embeddings' | 'probe_dimension';
ok: boolean;
warning?: boolean;
data: Record; // response, dimension, latency_ms, ...
error?: { code: string; message: string };
testedAt: string; // ISO timestamp
}
interface UIState {
language: 'en' | 'ru';
theme: 'light' | 'dark';
sidebarOpen: boolean;
// Результаты тестовых кнопок (§12.4 п.10-12)
testResults: TestResult[]; // последние 10 результатов
addTestResult: (r: TestResult) => void;
clearTestResults: () => void;
// Иконки, загруженные через UI (§12.4 п.11)
faviconUrl: string | null;
logoUrl: string | null;
ogImageUrl: string | null;
setIcon: (kind: 'favicon' | 'logo' | 'og_image', url: string) => void;
// Базовые setter-ы
setLanguage: (lang: 'en' | 'ru') => void;
toggleTheme: () => void;
}
```
### 12.3. SSE-обработчик
`src/api/sse.ts`:
```typescript
export class SSEClient {
private eventSource: EventSource | null = null;
private lastEventId: string | null = null;
connect(url: string, handlers: SSEHandlers): void {
this.eventSource = new EventSource(url, { withCredentials: true });
this.eventSource.onmessage = (e) => this.handle('message', e);
this.eventSource.addEventListener('phase_start', (e) => handlers.onPhaseStart?.(JSON.parse(e.data)));
this.eventSource.addEventListener('tool_call', (e) => handlers.onToolCall?.(JSON.parse(e.data)));
this.eventSource.addEventListener('scene_chunk', (e) => handlers.onSceneChunk?.(JSON.parse(e.data)));
this.eventSource.addEventListener('scene_complete', (e) => handlers.onSceneComplete?.(JSON.parse(e.data)));
this.eventSource.addEventListener('suggested_actions', (e) => handlers.onSuggestedActions?.(JSON.parse(e.data)));
this.eventSource.addEventListener('error', (e) => handlers.onError?.(JSON.parse(e.data)));
this.eventSource.addEventListener('done', (e) => {
handlers.onDone?.(JSON.parse(e.data));
this.disconnect();
});
this.eventSource.addEventListener('ping', (e) => {
this.lastEventId = e.lastEventId;
});
}
disconnect(): void {
this.eventSource?.close();
this.eventSource = null;
}
}
```
### 12.4. UX-требования
1. **Прогресс LLM-шагов.** Во время orchestrator показывает прогресс-бар с тремя фазами. Текущая фаза подсвечена, completed — зелёная, pending — серая.
2. **Пузырьки tool calls.** Каждый вызванный инструмент отображается как сворачиваемый пузырь в чате: `{tool: 'env_update', summary: 'player.stats.health -10', is_success: true}`. По клику разворачивается полный `arguments` и `result`.
3. **Streaming текста.** Phase 2 `scene_text` стримится посимвольно (или по словам для оптимизации). Текст появляется "печатной машинкой".
4. **Кнопка "Повторить генерацию".** Показывается если:
- SSE закрылся с `error` событием.
- Таймаут 60 секунд без событий.
- Пользователь вручную нажал "Отменить" → step помечается `failed`, доступен retry.
5. **Clarification modal.** Когда world_editor вызывает `ask_user`, появляется модальное окно с вопросом и опциональными вариантами. Поле ответа — textarea, кнопка "Отправить".
6. **Rollback.** Кнопка "Откатить последний ход" в меню шага. Подтверждение через modal.
7. **i18n.** Все UI-строки — через `react-i18next` `t('key')`. Язык переключается в шапке, сохраняется в `localStorage`. При смене языка — пере-рендер без перезагрузки страницы.
8. **Тёмная тема.** Tailwind `dark:` классы. Сохраняется в `uiStore.theme`.
9. **Responsive.** Mobile-first. На экранах <768px чат занимает всю ширину, environment panel сворачивается в drawer.
10. **Страница настроек админки — диагностические кнопки.** На `/admin` рядом с каждой группой настроек (LLM, Embeddings) выведены тестовые кнопки. Кнопки используют **текущие значения из формы** (query params), а не сохранённые в `settings` — это позволяет проверить конфигурацию до сохранения.
- **«Тест LLM»** (`TestLlmButton.tsx`) — рядом с группой `llm.*`. Вызывает `POST /api/admin/test/llm?api_url=&api_key=&model=`. Показывает `TestResultCard` с ответом модели, latency, token counts. При ошибке — красная карточка с сообщением.
- **«Тест LLM вызов инструментов»** (`TestLlmToolsButton.tsx`) — под кнопкой «Тест LLM». Вызывает `POST /api/admin/test/llm-tools?...`. Показывает, вернула ли модель `tool_calls` и какой именно инструмент вызван.
- **«Тест эмбеддинг»** (`TestEmbeddingsButton.tsx`) — рядом с группой `embeddings.*`. Вызывает `POST /api/admin/test/embeddings?...`. Показывает dimension, первые 5 значений вектора, latency.
- **«Авто-проба размерности»** (`ProbeDimensionButton.tsx`) — рядом с полем `embeddings.dimension`. Вызывает `POST /api/admin/test/embeddings/probe-dimension?...`. Возвращает dimension. Под кнопкой появляется inline-подсказка: `«Обнаружена размерность: 1536. [Сохранить в embeddings.dimension]»`. Клик по «Сохранить» подставляет значение в поле формы (не сохраняет в БД — для сохранения используется общая кнопка «Сохранить настройки» внизу страницы).
- Все четыре кнопки показывают `Spinner` во время выполнения (до 30 секунд timeout). При timeout — красная карточка с кнопкой «Повторить».
- Результаты тестов не пропадают при переключении вкладок — хранятся в `uiStore.testResults` (последние 10 результатов).
11. **Загрузка иконки через UI.** Компонент `IconUploader.tsx` на странице настроек позволяет загрузить:
- **favicon** (PNG 32×32 / 64×64, до 100KB) — обновляет `settings.ui.favicon_url`.
- **logo** (PNG/SVG, до 1MB) — обновляет `settings.ui.logo_url`. Логотип показывается в шапке приложения и на странице входа.
- **og_image** (PNG 1200×630, до 1MB) — обновляет `settings.ui.og_image_url` для Open Graph.
- Drag-and-drop зона + кнопка «Выбрать файл». Предпросмотр текущей иконки рядом с зоной загрузки.
- Вызывает `POST /api/admin/upload-icon` (multipart/form-data). После успешной загрузки показывает toast «Иконка обновлена» и обновляет предпросмотр.
- Поддерживаемые форматы: PNG, SVG, ICO (для favicon). Запрещены: всё остальное. MIME-type проверяется на бэке.
12. **Превью результата тестов.** `TestResultCard.tsx` показывает:
- Зелёная карточка при `ok=true`: основная информация (response / dimension / latency).
- Красная карточка при `ok=false`: код ошибки, сообщение, кнопка «Подробнее» (разворачивает stack trace из `error.message`).
- Жёлтая карточка при warning (например, `has_tool_calls=false` в тесте LLM-tools).
- Время выполнения теста сохраняется и отображается мелким шрифтом: `«Обновлено: 2026-06-20 14:23»`.
### 12.5. Маршруты
| Path | Component | Auth | Описание |
|---|---|---|---|
| `/login` | LoginPage | — | Вход |
| `/register` | RegisterPage | — | Регистрация (если есть админ) |
| `/register/admin` | RegisterAdminPage | token | Создание админа |
| `/worlds` | WorldsListPage | user | Список миров |
| `/worlds/new` | WorldCreatePage | user | Создание мира |
| `/worlds/:id/edit` | WorldEditPage | owner | Редактирование мира |
| `/worlds/:id/play` | WorldPlayPage | owner | Игра |
| `/admin` | AdminSettingsPage | admin | Настройки |
| `/admin/logs` | AdminLogsPage | admin | Логи LLM |
| `/admin/users` | AdminUsersPage | admin | Пользователи |
### 12.6. Локализация
Файлы `i18n/en.json` и `i18n/ru.json` — плоские ключи:
```json
{
"common.save": "Сохранить",
"common.cancel": "Отмена",
"common.retry": "Повторить",
"auth.login.title": "Вход в систему",
"auth.login.email_or_username": "Email или имя пользователя",
"worlds.list.title": "Мои миры",
"worlds.list.empty": "У вас пока нет миров. Создайте первый!",
"play.phase.1": "Планирование",
"play.phase.2": "Написание сцены",
"play.phase.3": "Сохранение",
"play.tool_call.env_update": "Изменение окружения",
"play.tool_call.entity_create": "Создание сущности",
"play.error.timeout": "Время ожидания истекло. Попробуйте ещё раз."
}
```
---
## 13. DevOps / Deployment
### 13.1. Docker Compose
`docker-compose.yml`:
```yaml
version: '3.9'
services:
db:
image: postgres:15-alpine
container_name: airpg_db
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
- ${DATA_DIR}/backups:/backups
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
qdrant:
image: qdrant/qdrant:v1.8.4
container_name: airpg_qdrant
environment:
QDRANT__SERVICE__GRPC_PORT: "6334"
QDRANT__SERVICE__HTTP_PORT: "6333"
QDRANT__LOG_LEVEL: ${QDRANT_LOG_LEVEL:-INFO}
# Включить авторизацию в проде: см. Qdrant docs, API key scopes
QDRANT__SERVICE__API_KEY: ${QDRANT_API_KEY:-}
volumes:
- qdrant_data:/qdrant/storage
- ${DATA_DIR}/qdrant_snapshots:/qdrant/snapshots
ports:
- "6333:6333" # HTTP (REST + Web UI)
- "6334:6334" # gRPC (используется qdrant-client)
healthcheck:
test: ["CMD-SHELL", "bash -c ':> /dev/tcp/127.0.0.1/6333' || exit 1"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: airpg_backend
env_file: .env
depends_on:
db:
condition: service_healthy
qdrant:
condition: service_healthy
ports:
- "8000:8000"
volumes:
- ${DATA_DIR}:/app/data
- ./backend/app:/app/app # для dev hot-reload
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: airpg_frontend
depends_on:
- backend
ports:
- "80:80"
volumes:
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
volumes:
db_data:
qdrant_data:
```
### 13.2. Backend Dockerfile
```dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
build-essential \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
### 13.3. Frontend Dockerfile + nginx
```dockerfile
# build stage
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# serve stage
FROM nginx:1.24-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
`nginx.conf`:
```nginx
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE: отключаем буферизацию
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
}
location / {
try_files $uri $uri/ /index.html;
}
}
```
### 13.4. .env — полный список переменных
```bash
# === Database ===
POSTGRES_DB=airpg
POSTGRES_USER=airpg
POSTGRES_PASSWORD=changeme_strong_password
POSTGRES_HOST=db
POSTGRES_PORT=5432
DATABASE_URL=postgresql+asyncpg://airpg:changeme_strong_password@db:5432/airpg
# === JWT ===
JWT_SECRET=change_this_to_random_64_char_string
JWT_ALGORITHM=HS256
JWT_ACCESS_EXPIRE_MINUTES=1440
JWT_REFRESH_EXPIRE_DAYS=7
# === Admin Setup ===
ADMIN_SETUP_TOKEN= # empty = generate random on startup
# === LLM ===
LLM_API_URL=http://localhost:11434/v1
LLM_API_KEY=
LLM_MODEL=qwen2.5-7b-instruct
# === Embeddings ===
# Если EMBEDDINGS_PROVIDER=openai и EMBEDDINGS_API_URL/EMBEDDINGS_API_KEY пусты —
# берутся LLM_API_URL/LLM_API_KEY (fallback, см. §11.6.2).
EMBEDDINGS_PROVIDER=offline_hash # or "openai"
EMBEDDINGS_API_URL= # пусто = fallback на LLM_API_URL
EMBEDDINGS_API_KEY= # пусто = fallback на LLM_API_KEY
EMBEDDINGS_MODEL=text-embedding-3-small
EMBEDDINGS_DIMENSION=1536 # кнопка «Авто-проба» в UI подставит правильное
EMBEDDINGS_TIMEOUT_SECONDS=30
EMBEDDINGS_BATCH_SIZE=32
EMBEDDINGS_CACHE_TTL_SECONDS=300
EMBEDDINGS_MAX_TEXT_CHARS=4000
# === Qdrant ===
QDRANT_URL=http://qdrant:6333
QDRANT_API_KEY= # пусто = без авторизации (dev); в prod обязателен
QDRANT_COLLECTION_PREFIX= # для multi-tenant деплоя
QDRANT_LOG_LEVEL=INFO
# === Context Manager ===
CONTEXT_GUARANTEED_MESSAGES=10
CONTEXT_COMPRESSION_THRESHOLD_MESSAGES=20
CONTEXT_COMPRESSION_THRESHOLD_TOKENS=6000
CONTEXT_SCENE_TEXT_TRUNCATE_TOKENS=500
CONTEXT_AUTO_RAG_ON_ENTITY_MENTION=false
CONTEXT_SAFETY_MARGIN_TOKENS=500
# === Data ===
DATA_DIR=/app/data
# === App ===
APP_ENV=development # development | staging | production
APP_LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
APP_CORS_ORIGINS=http://localhost:5173,http://localhost
# === Rate Limiting ===
RATE_LIMIT_PER_MINUTE=60
RATE_LIMIT_LLM_PER_MINUTE=10
```
### 13.5. CI/CD (GitHub Actions)
`.github/workflows/ci.yml`:
```yaml
name: CI
on: [push, pull_request]
jobs:
backend-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: airpg_test
POSTGRES_USER: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
qdrant:
image: qdrant/qdrant:v1.8.4
ports:
- 6333:6333
- 6334:6334
options: >-
--health-cmd ":> /dev/tcp/127.0.0.1/6333"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/airpg_test
QDRANT_URL: http://localhost:6333
EMBEDDINGS_PROVIDER: offline_hash
LLM_API_URL: http://localhost:11434/v1
LLM_MODEL: mock
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
- run: cd backend && pytest --cov=app --cov-report=xml --cov-fail-under=80
- uses: codecov/codecov-action@v4
frontend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: cd frontend && npm ci
- run: cd frontend && npm run lint
- run: cd frontend && npm run typecheck
- run: cd frontend && npm run test -- --coverage
- run: cd frontend && npx playwright test
docker-build:
needs: [backend-test, frontend-test]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: docker compose build
- run: docker compose push # если настроен registry
```
### 13.6. Бэкапы БД + Qdrant
Cron-скрипт `scripts/backup_all.sh` делает резервную копию PostgreSQL **и** Qdrant. Qdrant-снапшот создаётся через REST API `POST /collections/{collection_name}/snapshots`, файлы сохраняются в `${DATA_DIR}/qdrant_snapshots/` (проброшен volume в docker-compose).
```bash
#!/bin/bash
set -euo pipefail
BACKUP_DIR="${DATA_DIR}/backups"
QDRANT_SNAP_DIR="${DATA_DIR}/qdrant_snapshots"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mkdir -p "${BACKUP_DIR}" "${QDRANT_SNAP_DIR}"
# 1. PostgreSQL dump
PG_FILE="${BACKUP_DIR}/airpg_${TIMESTAMP}.sql.gz"
docker exec airpg_db pg_dump -U "${POSTGRES_USER}" "${POSTGRES_DB}" | gzip > "${PG_FILE}"
# 2. Qdrant snapshots для каждой коллекции
for COLLECTION in entities story_entries; do
# Запрашиваем создание снапшота на стороне Qdrant
SNAP_INFO=$(curl -s -X POST "${QDRANT_URL}/collections/${COLLECTION}/snapshots")
SNAP_NAME=$(echo "${SNAP_INFO}" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['name'])")
# Скачиваем снапшот в локальную папку
curl -s -o "${QDRANT_SNAP_DIR}/${COLLECTION}_${TIMESTAMP}.tar" \
"${QDRANT_URL}/collections/${COLLECTION}/snapshots/${SNAP_NAME}"
# Удаляем снапшот с Qdrant-инстанса (файл уже у нас)
curl -s -X DELETE "${QDRANT_URL}/collections/${COLLECTION}/snapshots/${SNAP_NAME}" > /dev/null
done
# Храним последние 30 дней
find "${BACKUP_DIR}" -name "airpg_*.sql.gz" -mtime +30 -delete
find "${QDRANT_SNAP_DIR}" -name "*.tar" -mtime +30 -delete
echo "Backup saved: ${PG_FILE}, Qdrant snapshots: ${QDRANT_SNAP_DIR}/${COLLECTION}_${TIMESTAMP}.tar (x2)"
```
**Восстановление** (disaster recovery):
```bash
# 1. PostgreSQL
zcat ${DATA_DIR}/backups/airpg_YYYYMMDD.sql.gz | docker exec -i airpg_db psql -U ${POSTGRES_USER} ${POSTGRES_DB}
# 2. Qdrant (для каждой коллекции)
curl -X PUT "${QDRANT_URL}/collections/entities/snapshots/upload" \
-F 'file=@${DATA_DIR}/qdrant_snapshots/entities_YYYYMMDD.tar'
```
Cron: `0 3 * * * /app/scripts/backup_all.sh` (ежедневно в 3:00).
### 13.7. Логирование
Структурированные логи в JSON через `structlog`:
```python
import structlog
logger = structlog.get_logger()
logger.info("llm_call",
stage="orchestrator_phase1",
world_id=str(world_id),
step_id=str(step_id),
model="qwen2.5-7b",
latency_ms=1234,
tokens=567,
)
```
В продакшене логи идут в stdout, docker собирает их через logging driver в centralized system (Loki/ELK).
### 13.8. Health-checks
- `GET /api/health` — liveness + readiness: `{status, db, qdrant, llm, embeddings, version}`. Проверки:
- `db` — `SELECT 1` через SQLAlchemy.
- `qdrant` — `GET /collections` через qdrant-client (проверяем что коллекции `entities` и `story_entries` существуют).
- `llm` — `false` если `settings.llm.api_url` пустой; `true` если последний вызов LLM был успешным (по `llm_call_logs` за последние 5 минут).
- `embeddings` — `false` если `provider=offline_hash`; `true` если последний embedding call был успешным.
- Docker healthcheck для backend: `curl -f http://localhost:8000/api/health || exit 1`.
- Docker healthcheck для db: `pg_isready`.
- Docker healthcheck для qdrant: TCP-проверка на порт 6333.
### 13.9. Monitoring (опционально, для прод)
- Prometheus metrics endpoint: `GET /metrics` (counter запросов, latency гистограммы, LLM call counters).
- Grafana dashboard: QPS, p50/p95 latency, LLM error rate, active SSE connections, DB pool size.
---
## 14. TDD-методология и тестовая инфраструктура
### 14.1. Red-Green-Refactor цикл
**Каждое изменение в коде начинается с теста.** Это обязательное правило для ИИ-агента-разработчика. Цикл:
1. **Red:** Напиши тест, который описывает желаемое поведение. Запусти — он падает (потому что реализации нет или она неверна).
2. **Green:** Напиши минимальную реализацию, чтобы тест прошёл. Не больше, не меньше.
3. **Refactor:** Улучши код, сохраняя тесты зелёными. Удали дублирование, вынеси общее, переименуй.
**Правило "один тест — одно поведение":** каждый тест проверяет ровно одну вещь. Если тест упал, должно быть ясно, что именно сломалось.
**Правило "тесты не тестируют реализацию, они тестируют контракт":** тесты не должны зависеть от внутренней структуры кода. Если рефакторинг поменял внутренности, но поведение сохранилось — тесты остаются зелёными.
### 14.2. Структура тестового проекта
```
backend/
├── tests/
│ ├── conftest.py # общие фикстуры
│ ├── unit/
│ │ ├── core/
│ │ │ ├── test_state_validator.py
│ │ │ ├── test_llm_client.py
│ │ │ ├── test_rag.py
│ │ │ ├── test_security.py
│ │ │ └── test_time_utils.py
│ │ ├── engine/
│ │ │ ├── test_game_master.py
│ │ │ ├── test_world_builder.py
│ │ │ ├── test_world_editor.py
│ │ │ ├── test_context.py
│ │ │ └── tools/
│ │ │ ├── test_registry.py
│ │ │ ├── test_env_update.py
│ │ │ ├── test_entity_tools.py
│ │ │ └── test_calc.py
│ │ └── prompts/
│ │ └── test_registry.py
│ ├── integration/
│ │ ├── api/
│ │ │ ├── test_auth.py
│ │ │ ├── test_worlds.py
│ │ │ ├── test_sessions.py
│ │ │ └── test_admin.py
│ │ ├── flows/
│ │ │ ├── test_world_builder_flow.py
│ │ │ ├── test_world_editor_flow.py
│ │ │ ├── test_orchestrator_flow.py
│ │ │ └── test_intro_scene_flow.py
│ │ └── sse/
│ │ └── test_sse_events.py
│ ├── e2e/
│ │ ├── test_full_session.py
│ │ └── test_admin_setup.py
│ └── fixtures/
│ ├── llm_replay/ # JSON-replay фикстуры LLM-ответов
│ │ ├── world_builder_basic.json
│ │ ├── orchestrator_combat.json
│ │ └── ...
│ ├── db_seeds/
│ │ ├── fantasy_world.json
│ │ └── sci-fi_world.json
│ └── schemas/
│ ├── valid_world.json
│ └── invalid_worlds.json
```
### 14.3. Фикстуры и моки
#### `conftest.py` — ключевые фикстуры
```python
import pytest
import pytest_asyncio
from httpx import AsyncClient
from testcontainers.postgres import PostgresContainer
from testcontainers.qdrant import QdrantContainer # см. https://github.com/testcontainers/testcontainers-python
@pytest_asyncio.fixture
async def db_session():
"""Изолированная БД для каждого теста через testcontainers."""
with PostgresContainer("postgres:15-alpine") as pg:
engine = create_async_engine(pg.get_connection_url())
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with AsyncSession(engine) as session:
yield session
@pytest_asyncio.fixture
async def qdrant_client():
"""Изолированный Qdrant для каждого теста через testcontainers."""
with QdrantContainer("qdrant/qdrant:v1.8.4") as qd:
client = AsyncQdrantClient(url=f"http://localhost:{qd.get_exposed_port(6333)}")
# Создаём тестовые коллекции с малой размерностью (для HashEmbedder)
await init_qdrant_collections(dimension=256)
yield client
@pytest_asyncio.fixture
async def mock_llm_client():
"""LLM-клиент, который возвращает предзаписанные ответы."""
return MockLLMClient(replay_dir="tests/fixtures/llm_replay/")
@pytest_asyncio.fixture
async def api_client(db_session, mock_llm_client):
"""FastAPI TestClient с подменённой БД и LLM."""
app = create_app(db_session, mock_llm_client)
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
@pytest.fixture
def sample_world():
"""Готовый мир для тестов."""
return load_fixture("db_seeds/fantasy_world.json")
@pytest.fixture
def admin_user(db_session):
user = User(email="admin@test", username="admin", is_admin=True, ...)
db_session.add(user)
await db_session.commit()
return user
@pytest.fixture
def auth_token(admin_user):
return create_access_token({"sub": str(admin_user.id)})
```
#### `MockLLMClient` — replay-based мок
```python
class MockLLMClient:
"""
Возвращает предзаписанные ответы вместо реальных LLM-вызовов.
Replay-файл: {stage: [sequence_of_responses]}.
Каждый вызов LLM возвращает следующий ответ из sequence.
"""
def __init__(self, replay_dir: str):
self.replay_dir = replay_dir
self._call_counts: dict[str, int] = {}
async def complete(self, stage: str, messages: list, tools: list = None, **kwargs) -> dict:
replay_file = Path(self.replay_dir) / f"{stage}.json"
replay_data = json.loads(replay_file.read_text())
idx = self._call_counts.get(stage, 0)
if idx >= len(replay_data):
raise RuntimeError(f"Replay exhausted for stage {stage}")
response = replay_data[idx]
self._call_counts[stage] = idx + 1
return response
async def stream(self, stage: str, messages: list, **kwargs):
replay_file = Path(self.replay_dir) / f"{stage}.json"
replay_data = json.loads(replay_file.read_text())
for chunk in replay_data:
yield chunk
```
**Запись replay-фикстур:** в dev-режиме флаг `LLM_RECORD_REPLAY=true` заставляет реальный `LlmClient` писать каждый ответ в JSON-файл. Эти файлы коммитятся в репозиторий и используются в тестах.
### 14.4. Примеры TDD-циклов
#### Пример 1: `state_validator.apply_patch`
**Red — пишем failing тест:**
```python
# tests/unit/core/test_state_validator.py
import pytest
from app.core.state_validator import apply_patch
class TestApplyPatch:
def test_set_operation_replaces_value(self):
state = {"player": {"name": "Эрик", "stats": {"health": 100}}}
patch = {"player.stats.health": 50}
new_state, errors = apply_patch(state, patch)
assert errors == []
assert new_state["player"]["stats"]["health"] == 50
def test_inc_operation_adds_to_value(self):
state = {"player": {"stats": {"health": 100}}}
patch = {"player.stats.health": {"op": "inc", "by": -10}}
new_state, errors = apply_patch(state, patch)
assert errors == []
assert new_state["player"]["stats"]["health"] == 90
def test_inc_on_non_integer_returns_error(self):
state = {"player": {"name": "Эрик"}}
patch = {"player.name": {"op": "inc", "by": 1}}
new_state, errors = apply_patch(state, patch)
assert errors == ["Cannot inc field 'player.name': not an integer"]
assert new_state is None
def test_unknown_operation_returns_error(self):
state = {"player": {"health": 100}}
patch = {"player.health": {"op": "multiply", "by": 2}}
_, errors = apply_patch(state, patch)
assert "Unknown operation: multiply" in errors[0]
def test_nested_path_with_array_index(self):
state = {"inventory": [{"item_id": "sword", "qty": 1}]}
patch = {"inventory[0].qty": 5}
new_state, errors = apply_patch(state, patch)
assert errors == []
assert new_state["inventory"][0]["qty"] == 5
```
**Green — минимальная реализация:**
```python
# app/core/state_validator.py
def apply_patch(state: dict, patch: dict) -> tuple[dict | None, list[str]]:
new_state = deepcopy(state)
errors = []
for path, op_spec in patch.items():
try:
if isinstance(op_spec, dict) and "op" in op_spec:
op = op_spec["op"]
if op == "inc":
_apply_inc(new_state, path, op_spec["by"])
elif op == "dec":
_apply_inc(new_state, path, -op_spec["by"])
elif op == "set":
_apply_set(new_state, path, op_spec.get("value"))
elif op == "append":
_apply_append(new_state, path, op_spec["value"])
elif op == "remove":
_apply_remove(new_state, path, op_spec["index"])
else:
errors.append(f"Unknown operation: {op}")
else:
_apply_set(new_state, path, op_spec)
except ValidationError as e:
errors.append(str(e))
if errors:
return None, errors
return new_state, []
```
**Refactor:** вынести общую логику навигации по path в `_resolve_path()`, переиспользовать в `apply_patch` и `validate_state`.
#### Пример 2: orchestrator Phase 1 integration test
```python
# tests/integration/flows/test_orchestrator_flow.py
import pytest
@pytest.mark.asyncio
async def test_orchestrator_phase1_calls_tools_and_submits_plan(
api_client, sample_world, mock_llm_client, auth_token
):
"""
Phase 1 должна:
1. Вызвать LLM минимум 1 раз.
2. LLM вызывает env_update (из mocking replay).
3. Завершается submit_plan.
"""
world_id = await create_world(api_client, auth_token, sample_world)
response = await api_client.post(
f"/api/sessions/worlds/{world_id}/iterate",
json={"action": "Я открываю дверь", "action_source": "custom"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert response.status_code == 202
stream_url = response.json()["stream_url"]
events = await collect_sse_events(api_client, stream_url, auth_token)
# Проверки
assert any(e["event"] == "phase_start" and e["data"]["phase"] == 1 for e in events)
assert any(e["event"] == "tool_call" and e["data"]["tool"] == "env_update" for e in events)
assert any(e["event"] == "phase_end" and e["data"]["phase"] == 1 for e in events)
assert events[-1]["event"] == "done"
```
### 14.5. Метрики покрытия
- **Целевое покрытие:** ≥ 80% для `app/core/`, `app/engine/`, `app/api/`. ≥ 60% для `app/prompts/` (сложно тестировать промпты).
- **Команда:** `pytest --cov=app --cov-report=html --cov-fail-under=80`.
- **CI блокирует merge**, если coverage упал ниже порога.
- **Покрытие по типам:**
- Unit: 90%+ (быстрые, изолированные).
- Integration: 70%+ (через API + testcontainers).
- E2E: ключевые сценарии (создание мира, итерация, редактирование).
### 14.6. Фронтенд-тесты
- **vitest** для unit-тестов stores и утилит.
- **@testing-library/react** для component-тестов.
- **Playwright** для E2E (полный сценарий: логин → создание мира → итерация).
```typescript
// frontend/src/stores/__tests__/sessionStore.test.ts
import { renderHook, act } from '@testing-library/react';
import { useSessionStore } from '../sessionStore';
test('iterate updates isIterating flag during call', async () => {
const { result } = renderHook(() => useSessionStore());
expect(result.current.isIterating).toBe(false);
await act(async () => {
await result.current.iterate('Открыть дверь', 'custom');
});
expect(result.current.isIterating).toBe(false); // после завершения
expect(result.current.steps).toHaveLength(1);
});
```
### 14.7. Когда тесты не нужны
- Pure data classes (Pydantic models без логики).
- Миграции Alembic (тестируются через `alembic upgrade head` в CI).
- UI-компоненты без логики (Button, Card).
---
## 15. Нефункциональные требования
### 15.1. Производительность
| Метрика | Цель | Как измерять |
|---|---|---|
| Latency `GET /api/worlds` (p95) | < 200ms | Prometheus histogram |
| Latency `POST /api/sessions/.../iterate` (от запроса до `iteration_complete`) | < 30s (одна фаза LLM ~5s) | SSE timestamp diff |
| Latency LLM single call (p50 / p95) | 2s / 8s | `llm_call_logs.latency_ms` |
| Throughput orchestrator iterations per minute | ≥ 10 (один сервер) | counter запросов |
| DB pool size | 20 connections (default), настраивается | SQLAlchemy metrics |
| SSE max concurrent connections per server | 200 | uvicorn worker config |
| Frontend first contentful paint | < 1.5s | Lighthouse |
| Frontend time to interactive | < 3s | Lighthouse |
**Оптимизации:**
- LLM streaming для Phase 2 — пользователь видит текст сразу, не ждёт завершения.
- Connection pool к БД — переиспользование соединений.
- Индексы на `worlds(owner_id, last_played_at)`, `entities(world_id, entity_type)` — критичны для списков.
- Pagination на всех list-эндпоинтах — не возвращать > 50 элементов за раз.
- Lazy loading фронтенда — code splitting по маршрутам.
### 15.2. Безопасность
| Требование | Реализация |
|---|---|
| **Аутентификация** | JWT (HS256), access token 24h, refresh token 7d. Blacklist refresh tokens при logout. |
| **Хеширование паролей** | bcrypt с cost factor 12. |
| **Authorisation** | RBAC: `user` / `admin`. Проверка `owner_id` на всех мутациях World/Entity. |
| **Валидация ввода** | Pydantic на всех API endpoints. JSON-schema на всех tool calls. |
| **SQL Injection** | Только SQLAlchemy parameterized queries. Никаких f-string SQL. |
| **XSS** | React по умолчанию экранирует. `dangerouslySetInnerHTML` запрещён без review. |
| **CSRF** | JWT в Authorization header (не cookie) — CSRF не применим. |
| **CORS** | `APP_CORS_ORIGINS` whitelist в .env. |
| **Rate limiting** | `RATE_LIMIT_PER_MINUTE=60` для обычных endpoints, `RATE_LIMIT_LLM_PER_MINUTE=10` для LLM-вызовов. Через `slowapi`. |
| **Secrets** | `.env` не коммитится. `.env.example` с placeholder значениями. API-ключи в `settings` шифруются на уровне приложения (Fernet). |
| **Audit log** | `llm_call_logs` — полный лог всех LLM-вызовов. `step_tool_calls` — аудит tool calls. |
| **Password policy** | ≥ 8 символов, минимум 1 буква + 1 цифра, blacklist top-1000 утечек. |
| **HTTPS** | nginx с Let's Encrypt в продакшене. HTTP→HTTPS redirect. |
| **Admin setup token rotation** | каждые 24 часа (cron task). |
### 15.3. Наблюдаемость (Observability)
**Логи:**
- Структурированный JSON через `structlog`.
- Уровни: DEBUG (dev), INFO (prod), WARNING, ERROR.
- Все LLM-вызовы логируются с `stage`, `world_id`, `step_id`, `latency_ms`, `tokens`, `status`.
- Все ошибки логируются со stacktrace.
**Метрики (Prometheus):**
- `http_requests_total{method, path, status}` — counter.
- `http_request_duration_seconds{method, path}` — histogram.
- `llm_calls_total{stage, status}` — counter.
- `llm_call_duration_seconds{stage}` — histogram.
- `sse_active_connections` — gauge.
- `db_pool_size{used, total}` — gauge.
**Tracing (опционально, OpenTelemetry):**
- span на каждый HTTP-запрос.
- span на каждый LLM-вызов с `traceparent` propagation.
- span на каждый tool call.
**Dashboards (Grafana):**
- Overview: QPS, p95 latency, error rate, active users.
- LLM: calls/min, error rate, token usage, latency per stage.
- DB: pool usage, slow queries, connections.
- SSE: active connections, average stream duration.
**Alerting:**
- LLM error rate > 10% за 5 минут → Slack alert.
- p95 latency > 10s → warning.
- DB pool > 80% → critical.
- Disk usage > 80% → warning.
### 15.4. Scalability
Текущая архитектура — **single-instance** (один backend, один db). Для масштабирования:
- **Вертикально:** больше CPU/RAM на backend, больше CPU на db.
- **Горизонтально (stateless backend):** N backend instances за load balancer. SSE работает через sticky sessions (LB направляет запросы одного клиента на один backend).
- **DB:** read replicas для `GET` запросов. Write master для мутаций. Партиционирование `llm_call_logs` по `created_at` (monthly).
- **Cache (опционально):** Redis для:
- Idempotency-Key кеша.
- Rate limit counters.
- Session state (для быстрых `GET /api/sessions/.../state`).
### 15.5. Backup & Recovery
| Что | Частота | Хранение | RTO | RPO |
|---|---|---|---|---|
| PostgreSQL dump | ежедневно 3:00 | 30 дней локально + S3 | 1h | 24h |
| WAL archiving | непрерывно | 7 дней | 15min | 5min |
| Volume `data/` | ежедневно | 14 дней | 1h | 24h |
| Config (.env) | при изменении | git + secret manager | 5min | immediate |
**Disaster recovery план:**
1. Поднять новый сервер с тем же docker-compose.
2. Восстановить БД из последнего дампа: `gunzip -c backup.sql.gz | psql`.
3. Применить pending migrations: `alembic upgrade head`.
4. Smoke-test: `GET /api/health` → 200.
5. Переключить DNS на новый сервер.
### 15.6. Совместимость
- **Браузеры:** Chrome 110+, Firefox 110+, Safari 16+, Edge 110+.
- **PostgreSQL:** 15+ (требуется для `gen_random_uuid()` по умолчанию).
- **Python:** 3.12+.
- **Node.js:** 20+ (LTS).
---
## 16. Стратегия обработки ошибок
### 16.1. Категории ошибок
| Категория | Примеры | Где обрабатывается | UX |
|---|---|---|---|
| **LLM errors** | timeout, 5xx от провайдера, invalid JSON в ответе, hallucination (несуществующий tool) | `LlmClient` + retry в `orchestrator` | SSE `error` event + кнопка "Повторить" |
| **Tool validation errors** | `state_validator` отклонил patch, unknown entity_type, name_conflict | `ToolRegistry.execute` | Возвращается LLM как `tool_result(ok=false)`, LLM может исправиться |
| **DB errors** | constraint violation, connection lost, deadlock | SQLAlchemy + retry decorator | 500 Internal Error, логируется |
| **Auth errors** | expired token, invalid token, not owner | FastAPI middleware | 401/403 JSON response |
| **Rate limit** | превышен лимит | slowapi middleware | 429 с `Retry-After` header |
| **SSE disconnect** | клиент отвалился, network issue | `EventSource` auto-reconnect + `Last-Event-ID` | Прогресс-бар "Переподключение..." |
| **Validation errors** | Pydantic на API, JSON-schema на tools | FastAPI + custom exception handler | 400 с детальным списком ошибок |
| **Business logic errors** | мир в `status=draft`, нет админа, etc. | Engine layer | 422 с описанием |
### 16.2. Retry-политики
| Операция | Retry | Backoff | Fallback |
|---|---|---|---|
| LLM call | 3 attempts | exponential: 1s, 2s, 4s | SSE error, кнопка "Повторить" |
| DB transaction (deadlock) | 3 attempts | fixed 100ms | 500 Internal Error |
| Tool execution | 0 (LLM сама решает повторить) | — | — |
| SSE reconnect | ∞ (until user closes) | 1s, 2s, 5s, 10s, 30s | "Переподключение..." UI |
| Embeddings API | 2 attempts | 2s, 5s | Skip embedding, log warning |
### 16.3. Стандартные коды ошибок API
(см. [раздел 6.7](#67-стандартные-коды-ошибок))
### 16.4. Логирование ошибок
```python
try:
result = await llm.complete(...)
except LLMTimeoutError as e:
logger.error("llm_timeout",
stage=stage,
world_id=str(world_id),
step_id=str(step_id),
timeout_seconds=settings["llm.timeout_seconds"],
error=str(e),
)
await sse_emitter.emit("error", {"code": "llm_timeout", "message": "LLM не ответила вовремя"})
raise
except Exception as e:
logger.exception("unexpected_error", stage=stage, error=str(e))
await sse_emitter.emit("error", {"code": "internal_error", "message": "Непредвиденная ошибка"})
raise
```
### 16.5. Fallback-стратегии
| Сценарий | Fallback |
|---|---|
| LLM не вызвала `submit_plan` за лимит | ORC форсит завершение: собирает summary из последних tool_results |
| LLM не вызвала `submit_step` после 2 ретраев | SSE `error` с кодом `writer_no_submit`, step помечается `failed`, доступен retry |
| `state_validator` отклонил patch 3 раза подряд | ORC добавляет в system message "Ваши предыдущие изменения отклонены валидатором. Проверь аргументы." |
| Embeddings API недоступен | `rag_query` возвращает пустой список + warning в лог. `rag_add` сохраняет запись с `embedding_status='pending'`, фоновый индексатор `app/workers/embedding_indexer.py` повторит через 30 секунд. После 3 неудачных попыток запись переходит в `embedding_status='failed'`, доступна для ручного retry через `POST /api/admin/embeddings/retry-failed`. |
| Qdrant недоступен | `rag_query` возвращает пустой список + error в лог. `rag_add` сохраняет запись с `embedding_status='pending'`. Health-check `qdrant` в `/api/health` становится `false`. Фоновый воркер блокирует обработку новых записей до восстановления Qdrant. Игровая сессия продолжается — RAG опционален, не критичный. |
| Размерность эмбеддингов не совпадает с размерностью Qdrant-коллекции | Backend логирует error при upsert. Админу показывается баннер: «Размерность модели (X) не совпадает с коллекцией (Y). [Пересоздать коллекцию и переиндексировать]». Кнопка запускает `POST /api/admin/embeddings/recreate-collections` (с подтверждением). |
| DB connection lost mid-transaction | Transaction rollback, SSE `error` с `code=db_error`. Step помечается `failed`. |
---
## 17. Roadmap реализации (по спринтам)
Дорожная карта разбита на 8 спринтов по 2 недели. Каждый спринт заканчивается demoable deliverable. После каждого спринта — ретроспектива и обновление этого ТЗ если выявлены новые требования.
### Sprint 1: Инфраструктура и БД (Foundation)
**Цель:** Рабочий docker-compose с пустым приложением, развёрнутые PostgreSQL + Qdrant со всеми таблицами/коллекциями, миграции, seed-данные.
**TDD-циклы:**
1. Test: `alembic upgrade head` создаёт все таблицы → Impl: миграция 001. (pgvector НЕ нужен.)
2. Test: `seed.py` заполняет `settings` → Impl: seed script (включая дефолтные `qdrant.*`, `embeddings.*`, `context.*` ключи).
3. Test: `GET /api/health` возвращает `{status:ok, db:true, qdrant:true, llm:false}` → Impl: health endpoint с проверкой Qdrant.
4. Test: `init_qdrant.py` создаёт коллекции `entities` и `story_entries` с payload-индексами → Impl: startup-хук.
**Артефакты:**
- `docker-compose.yml` с 4 сервисами (db, qdrant, backend, frontend) работает `docker compose up`.
- Все таблицы созданы, Qdrant-коллекции `entities` и `story_entries` созданы с payload-индексами на `world_id`.
- `settings` заполнены дефолтами (`embeddings.provider=offline_hash`, `embeddings.dimension=256` для dev).
- 2 встроенных пресета (fantasy, sci-fi) в `world_presets`.
**Приёмочные критерии:**
- `curl http://localhost/api/health` → 200 `{"status":"ok","db":true,"qdrant":true,"llm":false,"embeddings":true}`.
- `psql` показывает 10 таблиц (без pgvector extension).
- `curl http://localhost:6333/collections` возвращает обе коллекции.
- Coverage ≥ 80% на `app/migrations/` и `app/core/settings_service.py`.
### Sprint 2: Auth + Admin
**Цель:** Регистрация, вход, JWT, админ-панель настроек, создание первого админа.
**TDD-циклы:**
1. Test: `POST /api/register/admin` с верным токеном создаёт админа → Impl.
2. Test: `POST /api/auth/login` по email возвращает JWT → Impl.
3. Test: `POST /api/auth/login` по username возвращает JWT → Impl.
4. Test: защищённый endpoint без token → 401.
5. Test: `GET /api/admin/settings` без admin → 403.
6. Test: `PATCH /api/admin/settings` обновляет значение → Impl.
**Артефакты:**
- `/login`, `/register`, `/register/admin` страницы.
- `/admin` страница с формой настроек.
- Admin setup URL выводится в лог при старте.
### Sprint 3: World Builder
**Цель:** Создание мира из пресета или формы, генерация схем и environment.
**TDD-циклы:**
1. Test: `POST /api/worlds` с `mode=form` создаёт World со `status=draft` → Impl.
2. Test: WorldBuilder генерирует `schemas` через mock LLM → Impl.
3. Test: SSE `world_schema_generated` эмитится → Impl.
4. Test: `state_validator.validate_world` принимает сгенерированный мир → Impl.
5. Test: WorldBuilder dogenerates entities → Impl.
**Артефакты:**
- `/worlds/new` страница с выбором пресета/формы.
- SSE-стрим `world_builder` работает end-to-end.
- Минимум 1 replay-фикстура LLM-ответов для тестов.
### Sprint 4: World Editor
**Цель:** Редактирование мира через чат + ручные правки JSON.
**TDD-циклы:**
1. Test: `POST /api/worlds/{id}/edit` запускает world_editor stream → Impl.
2. Test: `ask_user` tool блокирует поток до ответа → Impl.
3. Test: `propose_changes` возвращает diff → Impl.
4. Test: `POST .../apply` коммитит staging → Impl.
5. Test: `POST .../discard` откатывает staging → Impl.
6. Test: optimistic lock: PATCH с устаревшим `updated_at` → 409.
**Артефакты:**
- `/worlds/:id/edit` страница с чатом + JSON-редактором.
- ClarificationModal компонент.
- Diff-viewer для `propose_changes`.
### Sprint 5: Orchestrator (Phase 1 + 2)
**Цель:** Игровая итерация с тремя фазами, без deferred triggers и summary.
**TDD-циклы:**
1. Test: Phase 1 вызывает LLM, LLM вызывает `env_update`, завершается `submit_plan` → Impl.
2. Test: Phase 2 LLM вызывает `submit_step`, scene_text стримится → Impl.
3. Test: Phase 3 persist коммитит state, обновляет `current_time` → Impl.
4. Test: `POST /api/sessions/.../iterate` возвращает SSE URL → Impl.
5. Test: `POST /api/sessions/.../retry` повторяет последний шаг → Impl.
6. Test: `POST /api/sessions/.../rollback` откатывает шаг → Impl.
**Артефакты:**
- `/worlds/:id/play` страница с чатом.
- ToolCallBubble компонент.
- Прогресс-бар фаз.
- Кнопки "Повторить", "Отменить", "Откатить".
### Sprint 6: Фронтенд-polish + i18n
**Цель:** Полный UI с локализацией, тёмной темой, responsive.
**TDD-циклы:**
1. Test: переключение языка re-renderит UI без перезагрузки → Impl.
2. Test: тёмная тема применяется через Tailwind `dark:` → Impl.
3. Test: mobile layout <768px сворачивает sidebar → Impl.
4. Test: SSE reconnect восстанавливает стрим → Impl.
**Артефакты:**
- `i18n/en.json` и `i18n/ru.json` заполнены.
- Тёмная/светлая тема.
- Mobile-first layout.
- E2E тесты на Playwright.
### Sprint 7: RAG + Deferred Triggers + Summary + Context Optimization
**Цель:** Полная функциональность Phase 3 + RAG на Qdrant + контекстная оптимизация.
**TDD-циклы:**
1. Test: `rag_query` через Qdrant возвращает релевантные StoryEntry (с mock embeddings) → Impl (§11.1.4).
2. Test: `rag_add` создаёт StoryEntry + upsert-ит точку в Qdrant `story_entries` → Impl (§11.1.5).
3. Test: фоновый `embedding_indexer` подхватывает `embedding_status='pending'` и индексирует → Impl (§11.6.4).
4. Test: `build_embedder()` fallback-правило: если `embeddings.provider='openai'` и `api_url`/`api_key` пустые → берутся `llm.*` (§11.6.2) → Impl.
5. Test: `POST /api/admin/test/embeddings/probe-dimension` возвращает корректную dimension для mock-провайдера → Impl.
6. Test: `schedule_trigger` создаёт DeferredTrigger → Impl.
7. Test: Phase 3.1 запускает subagent для pending triggers → Impl.
8. Test: Phase 3.2 генерирует summary при превышении `context.compression_threshold_messages` → Impl.
9. Test: Phase 3.3 вызывает `suggest_actions` → Impl.
10. Test: контекстный менеджер `build_context()` возвращает messages, умещающиеся в бюджет токенов (с деградацией: recent → rag → summary) → Impl (§11.5).
11. Test: изоляция миров — `rag_query(world_id=A)` не возвращает результаты мира B (через payload-фильтр Qdrant) → Impl.
12. Test: удаление мира каскадно чистит точки в Qdrant (`_cleanup_qdrant`) → Impl (§11.1.6).
**Артефакты:**
- `HashEmbedder` и `OpenAIEmbedder` реализации.
- `app/core/rag.py` — двухстадийный retrieval (Qdrant → PostgreSQL).
- `app/workers/embedding_indexer.py` — фоновый индексатор.
- `app/engine/context.py` — контекстный менеджер с бюджетом токенов и деградацией.
- Эндпоинты `/api/admin/test/llm`, `/test/llm-tools`, `/test/embeddings`, `/test/embeddings/probe-dimension` (см. §6.5).
- `run_subagent` tool работает.
- Summary в `story_entries` с `entry_type='summary'`.
- Admin-панель показывает логи LLM с фильтрами + диагностические кнопки на странице настроек (см. §12.4).
### Sprint 8: Polish, Performance, Production-readiness
**Цель:** Production-ready deploy, метрики, бэкапы, документация.
**TDD-циклы:**
1. Test: Prometheus `/metrics` endpoint отдаёт метрики → Impl.
2. Test: backup script создаёт gzip dump → Impl.
3. Test: rate limiter возвращает 429 при превышении → Impl.
4. Test: load test (50 concurrent users) проходит без ошибок → Impl (Locust script).
5. Test: security scan (bandit, pip-audit, npm audit) без critical уязвимостей → Impl.
**Артефакты:**
- Grafana dashboard JSON.
- Backup cron настроен.
- `docs/deployment.md` — пошаговый гайд.
- `docs/api.md` — автогенерация из OpenAPI.
- README.md с quickstart.
### Приоритеты и зависимости
```mermaid
graph TD
S1[Sprint 1: Foundation] --> S2[Sprint 2: Auth+Admin]
S2 --> S3[Sprint 3: World Builder]
S3 --> S4[Sprint 4: World Editor]
S3 --> S5[Sprint 5: Orchestrator P1+P2]
S4 --> S5
S5 --> S6[Sprint 6: Frontend polish]
S5 --> S7[Sprint 7: RAG + Triggers + Summary]
S6 --> S8[Sprint 8: Production]
S7 --> S8
style S1 fill:#e8f5e9
style S8 fill:#fff3e0
```
**MVP (после Sprint 5):** игрок может создать мир, играть (без RAG и triggers), редактировать мир. Это демонстрируемый продукт.
**Full release (после Sprint 8):** production-ready система со всеми фичами.
---
## 18. Чек-листы и приёмочные критерии
### 18.1. Definition of Done (DoD) для каждой фичи
Фича считается завершённой только если **все** пункты выполнены:
- [ ] Код написан по TDD (сначала failing test, потом реализация).
- [ ] Все unit-тесты проходят: `pytest tests/unit/ -v`.
- [ ] Все integration-тесты проходят: `pytest tests/integration/ -v`.
- [ ] Coverage на изменённых файлах ≥ 80%.
- [ ] Линтер проходит без ошибок: `ruff check app/ tests/`.
- [ ] Type-check проходит: `mypy app/`.
- [ ] Docstrings на всех новых public функциях (Google style).
- [ ] Если добавлена новая настройка — она есть в `settings` seed и в `.env.example`.
- [ ] Если добавлен новый API endpoint — он есть в OpenAPI схеме (`/api/openapi.json`).
- [ ] Если добавлен новый tool — он зарегистрирован в `ToolRegistry` и описан в этом ТЗ.
- [ ] Если добавлена новая БД-таблица — создана Alembic миграция с `upgrade()` и `downgrade()`.
- [ ] Если изменён SSE-протокол — обновлён раздел 7 этого ТЗ.
- [ ] Если изменён промпт — обновлён раздел 10.
- [ ] CHANGELOG.md обновлён.
- [ ] PR reviewed и approved.
- [ ] CI зелёный.
### 18.2. Pre-merge чек-лист
- [ ] `pytest --cov=app --cov-fail-under=80` → exit 0.
- [ ] `cd frontend && npm run lint && npm run typecheck && npm run test` → exit 0.
- [ ] `cd frontend && npx playwright test` → exit 0 (если затронут E2E).
- [ ] `docker compose build` → exit 0.
- [ ] `docker compose up` → приложение стартует, `GET /api/health` → 200.
- [ ] Smoke-тест вручную: создать мир → сделать итерацию → откатить.
- [ ] Нет новых `console.log` / `print` в коде (кроме dev-скриптов).
- [ ] Нет захардкоженных секретов (использовать `settings`).
### 18.3. Pre-deploy чек-лист (staging → production)
- [ ] Миграции применены на staging: `alembic upgrade head`.
- [ ] Rollback протестирован: `alembic downgrade -1` на staging.
- [ ] Backup сделан перед деплоем.
- [ ] `.env` production обновлён (если новые переменные).
- [ ] LLM endpoint доступен из production сервера (firewall).
- [ ] HTTPS сертификат валиден.
- [ ] Health-check после деплоя: `GET /api/health` → 200 с `llm:true`.
- [ ] Smoke-тест: логин → создать мир → итерация.
- [ ] Мониторинг: метрики идут в Prometheus, логи в centralized system.
- [ ] Команда уведомлена о деплое.
### 18.4. Чек-лист безопасности (ежемесячный аудит)
- [ ] `pip-audit` на backend dependencies → нет critical.
- [ ] `npm audit` на frontend dependencies → нет critical.
- [ ] `bandit -r app/` → нет high severity.
- [ ] Все пароли в `settings` зашифрованы (Fernet).
- [ ] JWT secret не утёк (проверка через git history).
- [ ] Admin setup token ротирован за последние 24h.
- [ ] Бэкапы восстанавливаемы (test restore на staging).
- [ ] CORS origins не содержит `*`.
### 18.5. Чек-лист для ИИ-агента-разработчика
Перед началом работы над любой задачей:
- [ ] Прочитал этот ТЗ целиком (хотя бы разделы 1, 2, 3, и релевантные задаче).
- [ ] Прочитал `worklog.md` — что уже сделали другие агенты.
- [ ] Понял, в каком спринте задача и какие зависимости.
- [ ] Написал TODO-список для задачи.
- [ ] Для каждой подзадачи: RED (тест) → GREEN (реализация) → REFACTOR.
- [ ] После завершения — обновил `worklog.md` согласно шаблону.
- [ ] Если обнаружил неоднозначность в ТЗ — задал вопрос (не додумывал).
### 18.6. Метрики успеха проекта
| Метрика | Цель | Как измерять |
|---|---|---|
| Time to first iteration (от установки до первой игры) | < 30 минут | Manual test |
| Удержание (D7 retention) | ≥ 30% | Аналитика (когда будет) |
| Средняя длина сессии | ≥ 20 итераций | `steps` count per world |
| LLM error rate | < 5% | `llm_call_logs` WHERE status != 'ok' |
| Crash rate | < 1% итераций | `steps` WHERE status='failed' |
| Coverage | ≥ 80% | CI |
| p95 latency iteration | < 30s | SSE timestamps |
---
## Приложение A. Ссылки и референсы
- **OpenAI function calling docs:** https://platform.openai.com/docs/guides/function-calling
- **Qdrant docs:** https://qdrant.tech/documentation/
- **Qdrant Python client:** https://github.com/qdrant/qdrant-client
- **Qdrant payload filters:** https://qdrant.tech/documentation/concepts/filtering/
- **Qdrant snapshots / backup:** https://qdrant.tech/documentation/concepts/snapshots/
- **FastAPI docs:** https://fastapi.tiangolo.com/
- **SQLAlchemy 2.x async:** https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html
- **sse-starlette:** https://github.com/sysid/sse-starlette
- **Alembic:** https://alembic.sqlalchemy.org/
- **TDD (Martin Fowler):** https://martinfowler.com/bliki/TestDrivenDevelopment.html
- **structlog:** https://www.structlog.org/
- **tiktoken (оценка токенов):** https://github.com/openai/tiktoken
## Приложение B. Шаблон worklog.md записи
```markdown
---
Task ID:
Agent:
Task: <краткое описание задачи>
Work Log:
- Прочитал ТЗ разделы 5, 8, 9.3
- Прочитал worklog — предыдущие задачи S5-1, S5-2 завершены
- Написал failing тест test_orchestrator_phase1_calls_env_update
- Реализовал минимально: ToolRegistry.execute, env_update tool
- Тест зелёный
- Рефакторинг: вынес _resolve_path в общий utils
- Все тесты зелёные, coverage 85%
Stage Summary:
- Реализован Phase 1 orchestrator с tool execution loop
- Добавлен MockLLMClient для replay-тестирования
- Создана replay-фикстура orchestrator_basic.json
- Изменена схема: добавлена step_tool_calls таблица (миграция 003)
- Open вопрос: нужно ли ограничение на количество одновременных tool calls в одном response?
```
## Приложение C. Глоссарий сокращений
| Сокращение | Расшифровка |
|---|---|
| TZ | Техническое задание |
| TDD | Test-Driven Development |
| GM | Game Master |
| SSE | Server-Sent Events |
| RAG | Retrieval-Augmented Generation |
| JWT | JSON Web Token |
| RBAC | Role-Based Access Control |
| NFR | Non-Functional Requirement |
| DoD | Definition of Done |
| ADR | Architecture Decision Record |
| ORM | Object-Relational Mapping |
| ASGI | Asynchronous Server Gateway Interface |
| SPA | Single Page Application |
| UX | User Experience |
| FK | Foreign Key |
| PK | Primary Key |
| UK | Unique Key |