Files
ai-rpg/docs/AI-RPG_TZ_TDD.md
2026-06-20 19:13:05 +03:00

217 KiB
Raw Permalink Blame History

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. Глоссарий терминов
  2. Обзор проекта и цели
  3. Архитектура системы (high-level)
  4. Стек технологий
  5. Детальная схема БД
  6. API спецификация (OpenAPI)
  7. SSE-протокол
  8. Tool-сигнатуры (JSON-schema)
  9. Потоки (flows) с sequence-диаграммами
  10. Промпт-шаблоны и контекстный менеджер
  11. RAG-подсистема и валидация состояния
  12. Фронтенд-архитектура
  13. DevOps / Deployment
  14. TDD-методология и тестовая инфраструктура
  15. Нефункциональные требования
  16. Стратегия обработки ошибок
  17. Roadmap реализации (по спринтам)
  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

flowchart TB
    subgraph "Пользователь"
        Player[Игрок]
        Admin[Администратор]
    end

    subgraph "Браузер"
        FE[React Frontend<br/>Vite + TS + zustand]
    end

    subgraph "Сервер приложений"
        Nginx[nginx<br/>статика + reverse-proxy]
        API[FastAPI Backend<br/>uvicorn]
        Engine[Engine Layer<br/>game_master, world_builder,<br/>world_editor, context]
        Core[Core Layer<br/>llm_client, rag, validator,<br/>security, settings]
    end

    subgraph "Внешние сервисы"
        LLM[LLM Provider<br/>OpenAI-compatible API]
    end

    subgraph "Хранилище"
        PG[(PostgreSQL 15+<br/>реляционные данные)]
        QD[(Qdrant<br/>векторный индекс)]
        Vol[(Volume data/<br/>бэкапы, артефакты)]
    end

    Player --> FE
    Admin --> FE
    FE -->|HTTP/SSE| Nginx
    Nginx -->|/api/*| API
    API --> Engine
    Engine --> Core
    Core -->|httpx| LLM
    Core -->|embeddings API| LLM
    Engine -->|SQLAlchemy async| PG
    Core -->|SQLAlchemy async| PG
    Core -->|qdrant-client| QD
    API -->|логи| Vol

    style FE fill:#e1f5ff
    style API fill:#fff3e0
    style PG fill:#e8f5e9
    style QD fill:#ede7f6
    style LLM fill:#fce4ec

3.2. Слои бэкенда

Бэкенд разбит на шесть слоёв (директории внутри app/). Каждый слой имеет одну ответственность и не должен вызывать слои "через голову" (например, api/ не должен напрямую дёргать core/, минуя engine/).

Слой Директория Ответственность Зависит от
API app/api/ FastAPI-роутеры: auth, admin, worlds, sessions, presets, misc. Только HTTP-логика, валидация Pydantic-схемами, вызов engine/core. engine, core, schemas
Engine app/engine/ Движок игры: game_master (основная итерация), world_builder, world_editor, context (построение промптов), tools/ (инструменты). core, models, prompts
Core app/core/ Сквозные сервисы: llm (LLM-клиент), settings_service, rag, state_validator, security (JWT). models
Models app/models/ SQLAlchemy-модели всех таблиц.
Prompts app/prompts/ Все системные промпты в виде Python-строк с str.format() интерполяцией. get_prompt(stage, language) — единственная точка доступа.
Schemas app/schemas/ Pydantic-схемы для request/response API. Не путать с JSON-schema мира — это разные сущности.

Правило циклических зависимостей: слои api → engine → core → models образуют однонаправленный граф. prompts и schemas — листья, их может импортировать кто угодно, они никого не импортируют.

3.3. Фронтенд-архитектура (overview)

Подробно — в разделе 12. Кратко:

  • React 18 + TypeScript + Vite.
  • Состояние: zustand с доменными сторами: authStore, uiStore, worldsStore, sessionStore.
  • Локализация: react-i18next, два языка (en, ru), расширяемо.
  • Стили: Tailwind CSS.
  • UI-компоненты: собственная минимальная библиотека в src/components/ui/ (Button, Card, Input, Modal, Navbar) + shadcn-стиль cn-утилита.
  • Маршруты: React Router v6. Страницы: /login, /register, /worlds, /worlds/new, /worlds/:id/edit, /worlds/:id/play, /admin.

3.4. Принципы взаимодействия

  1. REST + SSE. Команды от клиента — REST (POST/GET/PUT/DELETE). Долгие операции (orchestrator, world_builder) возвращают SSE-стрим с прогрессом.
  2. JWT в Authorization header. Все эндпоинты, кроме /auth/* и /register/*, требуют Authorization: Bearer <token>.
  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-диаграмма

erDiagram
    users ||--o{ world_presets : owns
    users ||--o{ worlds : owns
    users ||--o{ llm_call_logs : triggers

    world_presets ||--o{ worlds : "instantiated from"

    worlds ||--o{ entities : contains
    worlds ||--o{ steps : "has iterations"
    worlds ||--o{ deferred_triggers : "schedules"
    worlds ||--o{ story_entries : "indexes facts"

    steps ||--o{ llm_call_logs : "produces"
    steps ||--o{ step_tool_calls : "executes"

    settings {
        uuid id PK
        string key UK
        jsonb value
        timestamp updated_at
    }

    users {
        uuid id PK
        string email UK
        string username UK
        string password_hash
        boolean is_admin
        boolean is_active
        timestamp created_at
        timestamp last_login_at
    }

    world_presets {
        uuid id PK
        uuid owner_id FK
        string name
        text description
        string language
        jsonb rules
        jsonb time_schema
        jsonb schemas
        jsonb environment_schema
        jsonb environment_initial
        string status
        boolean is_public
        timestamp created_at
        timestamp updated_at
    }

    worlds {
        uuid id PK
        uuid owner_id FK
        uuid preset_id FK
        string name
        text description
        string language
        jsonb rules
        jsonb time_schema
        jsonb schemas
        jsonb environment_schema
        jsonb environment
        jsonb plot_rails
        string current_time
        string status
        timestamp created_at
        timestamp updated_at
        timestamp last_played_at
    }

    entities {
        uuid id PK
        uuid world_id FK
        string entity_type
        string name
        jsonb data
        boolean is_in_environment
        string qdrant_point_id
        timestamp created_at
        timestamp updated_at
        timestamp deleted_at
    }

    steps {
        uuid id PK
        uuid world_id FK
        int sequence_number
        string player_action
        text scene_text
        jsonb suggested_actions
        jsonb tool_calls_summary
        jsonb metadata
        uuid phase1_log_id FK
        uuid phase2_log_id FK
        timestamp created_at
        timestamp deleted_at
    }

    deferred_triggers {
        uuid id PK
        uuid world_id FK
        string fire_at
        string event_type
        jsonb payload
        boolean is_fired
        timestamp created_at
        timestamp fired_at
    }

    story_entries {
        uuid id PK
        uuid world_id FK
        text content
        string entry_type
        string qdrant_point_id
        jsonb metadata
        timestamp created_at
    }

    llm_call_logs {
        uuid id PK
        uuid user_id FK
        uuid world_id FK
        uuid step_id FK
        string stage
        string model
        jsonb request_messages
        jsonb response_message
        jsonb tool_calls
        int prompt_tokens
        int completion_tokens
        int latency_ms
        string status
        text error_message
        timestamp created_at
    }

    step_tool_calls {
        uuid id PK
        uuid step_id FK
        string tool_name
        jsonb arguments
        jsonb result
        boolean is_success
        timestamp executed_at
    }

5.2. Описание таблиц

5.2.1. users

Колонка Тип Ограничения Назначение
id UUID PK, default gen_random_uuid() Первичный ключ
email VARCHAR(255) UNIQUE, NOT NULL Email пользователя
username VARCHAR(64) UNIQUE, NOT NULL Логин
password_hash VARCHAR(255) NOT NULL bcrypt hash
is_admin BOOLEAN NOT NULL, default false Флаг администратора
is_active BOOLEAN NOT NULL, default true Активна ли учётка
created_at TIMESTAMPTZ NOT NULL, default now() Дата создания
last_login_at TIMESTAMPTZ nullable Последний вход

Индексы: idx_users_email (UNIQUE), idx_users_username (UNIQUE).

5.2.2. settings

Одна таблица для всех админ-настроек (key-value с JSONB value). Это позволяет добавлять новые настройки без миграций схемы.

Колонка Тип Ограничения Назначение
id UUID PK
key VARCHAR(128) UNIQUE, NOT NULL Ключ настройки (например llm.api_url)
value JSONB NOT NULL Значение (строка, число, объект, массив)
description TEXT nullable Человекочитаемое описание
updated_at TIMESTAMPTZ NOT NULL, default now() Последнее изменение

Ключи настроек (seed-данные):

Ключ Тип value Назначение
llm.api_url string URL OpenAI-compatible endpoint
llm.api_key string API-ключ (зашифрован на уровне приложения)
llm.model string Имя модели (например qwen2.5-7b-instruct)
llm.temperature_orchestrator number Phase 1, default 0.7
llm.temperature_writer number Phase 2, default 0.85
llm.max_tokens integer Лимит completion
llm.timeout_seconds integer Timeout на вызов, default 60
embeddings.provider string offline_hash | openai. Если openai и embeddings.api_url/api_key пустые — fallback на llm.api_url/api_key (см. §11.6.2).
embeddings.api_url string URL OpenAI-compatible embeddings endpoint. Если пусто — fallback на llm.api_url.
embeddings.api_key string API-ключ embeddings. Если пусто — fallback на llm.api_key.
embeddings.model string Имя модели эмбеддингов (default text-embedding-3-small)
embeddings.dimension integer Размерность вектора, default 1536. Кнопка «Авто-проба» в UI определяет автоматически (§11.6.3).
embeddings.timeout_seconds integer Timeout embeddings API, default 30
embeddings.batch_size integer Размер батча для embedding API, default 32
embeddings.cache_ttl_seconds integer TTL LRU-кеша для query embeddings, default 300
embeddings.max_text_chars integer Урезка текста перед эмбеддингом, default 4000
context.guaranteed_messages integer Сколько последних сообщений всегда в контексте, default 10
context.compression_threshold_messages integer Порог сжатия, default 20
context.compression_threshold_tokens integer Альтернативный порог по токенам, default 6000
context.scene_text_truncate_tokens integer Урезка scene_text в recent messages, default 500
context.auto_rag_on_entity_mention boolean Авто-вызов rag_query при упоминании сущности, default false
context.safety_margin_tokens integer Резерв от края context window, default 500
qdrant.url string URL Qdrant-инстанса (например http://qdrant:6333)
qdrant.api_key string API-ключ Qdrant (если включена авторизация)
qdrant.collection_prefix string Префикс для коллекций (для multi-tenant деплоя), default ""
game.deferred_triggers_enabled boolean Включены ли отложенные триггеры, default true
game.max_substeps_per_iteration integer Лимит шагов в Phase 1, default 8
game.max_suggested_actions integer Лимит действий в конце итерации, default 3
ui.page_title string Заголовок вкладки браузера
ui.favicon_url string URL favicon (загружается через /api/admin/upload-icon, см. §6.5)
ui.logo_url string URL логотипа в шапке приложения
ui.og_image_url string URL Open Graph image (для соц-превью)
admin.setup_token string Токен для создания первого админа

5.2.3. world_presets

Дополнительно спроектированные колонки:

Колонка Тип Назначение
is_public BOOLEAN, default false Опубликован ли пресет в галерее
version INTEGER, default 1 Версия пресета для контроля обновлений

5.2.4. worlds

Колонка Тип Ограничения Назначение
id UUID PK
owner_id UUID FK→users.id, NOT NULL Владелец
preset_id UUID FK→world_presets.id, nullable Если создан из пресета
name VARCHAR(255) NOT NULL Название
description TEXT nullable Описание
language VARCHAR(8) NOT NULL Код языка (en, ru)
rules JSONB NOT NULL, default '[]' Массив строк правил
time_schema JSONB NOT NULL, default '{"hours_in_day":24,"initial_date":"day_1_hour_8"}' Схема времени
schemas JSONB NOT NULL Массив схем сущностей
environment_schema JSONB NOT NULL Массив определений полей окружения
environment JSONB NOT NULL Живое состояние окружения
plot_rails JSONB NOT NULL, default '{"hooks":[],"current_goals":[],"completed_goals":[]}' Сюжетные рельсы (дублируются из environment для быстрого доступа)
current_time VARCHAR(32) NOT NULL Текущее игровое время в формате day_D_hour_H[_min_M]
status VARCHAR(32) NOT NULL, default 'draft' draft | ready | archived
intro_scene TEXT nullable Сгенерированная вступительная сцена
created_at TIMESTAMPTZ NOT NULL, default now()
updated_at TIMESTAMPTZ NOT NULL, default now()
last_played_at TIMESTAMPTZ nullable Последняя итерация

Индексы: idx_worlds_owner_id, idx_worlds_status, idx_worlds_last_played_at.

5.2.5. entities

Колонка Тип Ограничения Назначение
id UUID PK
world_id UUID FK→worlds.id, NOT NULL Принадлежность миру
entity_type VARCHAR(64) NOT NULL Тип из world.schemas (character, item, location, ...)
name VARCHAR(255) NOT NULL Имя/название
data JSONB NOT NULL Полные данные сущности по schema
is_in_environment BOOLEAN NOT NULL, default false Находится ли сущность в environment (для быстрого доступа без JOIN)
qdrant_point_id VARCHAR(64) nullable ID точки в Qdrant-коллекции entities. NULL = вектор не посчитан (фоновый job дозаполнит). Текст для эмбеддинга формируется из name + JSON data.
embedding_status VARCHAR(16) NOT NULL, default 'pending' pending | indexed | failed. Позволяет фоновому воркеру выбирать «голодные» записи.
created_at TIMESTAMPTZ NOT NULL, default now()
updated_at TIMESTAMPTZ NOT NULL, default now()
deleted_at TIMESTAMPTZ nullable Soft delete. При soft-delete точка в Qdrant тоже удаляется через qdrant_client.delete() (best-effort).

Индексы: idx_entities_world_id, idx_entities_world_type (world_id, entity_type), idx_entities_embedding_status (embedding_status) WHERE deleted_at IS NULL — для фонового индексатора. Векторный поиск выполняется в Qdrant, не в PostgreSQL; IVFFLAT/HNSW-индексы здесь не нужны.

5.2.6. steps

Колонка Тип Ограничения Назначение
id UUID PK
world_id UUID FK→worlds.id, NOT NULL
sequence_number INTEGER NOT NULL Монотонный номер шага в мире
player_action TEXT NOT NULL Действие игрока (или __suggested__:N если выбрал заготовку)
scene_text TEXT nullable Нарратив из Phase 2
scene_delta_time VARCHAR(32) nullable Дельта времени из Phase 2 ([year_Y][days_D][hours_H][min_M])
suggested_actions JSONB NOT NULL, default '[]' Массив 1-3 следующих действий
tool_calls_summary JSONB NOT NULL, default '[]' Краткая сводка вызванных инструментов (для UI-пузырьков)
metadata JSONB NOT NULL, default '{}' Доп. метаданные (latency, token counts, model versions)
phase1_log_id UUID FK→llm_call_logs.id, nullable Лог Phase 1
phase2_log_id UUID FK→llm_call_logs.id, nullable Лог Phase 2
phase3_summary_log_id UUID FK→llm_call_logs.id, nullable Лог summary (если был)
created_at TIMESTAMPTZ NOT NULL, default now()
deleted_at TIMESTAMPTZ nullable Soft delete (для отката)

Ограничения: UNIQUE (world_id, sequence_number) — номера не могут дублироваться.

Индексы: idx_steps_world_seq (world_id, sequence_number DESC), idx_steps_created_at.

5.2.7. deferred_triggers

Колонка Тип Ограничения Назначение
id UUID PK
world_id UUID FK→worlds.id, NOT NULL
fire_at VARCHAR(32) NOT NULL Игровое время срабатывания
event_type VARCHAR(64) NOT NULL Тип события (например spawn_enemy, weather_change, quest_update)
payload JSONB NOT NULL, default '{}' Данные события
is_fired BOOLEAN NOT NULL, default false Сработало ли
created_at TIMESTAMPTZ NOT NULL, default now()
fired_at TIMESTAMPTZ nullable Когда сработало

Индексы: idx_triggers_world_pending (world_id, is_fired, fire_at) — для быстрого поиска pending triggers, которые пора активировать.

5.2.8. story_entries

Колонка Тип Ограничения Назначение
id UUID PK
world_id UUID FK→worlds.id, NOT NULL
content TEXT NOT NULL Текст факта
entry_type VARCHAR(64) NOT NULL fact | event | relationship | secret | summary
qdrant_point_id VARCHAR(64) nullable ID точки в Qdrant-коллекции story_entries. NULL = вектор ещё не посчитан.
embedding_status VARCHAR(16) NOT NULL, default 'pending' pending | indexed | failed
metadata JSONB NOT NULL, default '{}' Связи с entity_id, step_id, и т.д.
created_at TIMESTAMPTZ NOT NULL, default now()

Индексы: idx_story_world_type (world_id, entry_type), idx_story_status (embedding_status) WHERE qdrant_point_id IS NULL — для фонового индексатора. Векторный поиск — в Qdrant; PostgreSQL хранит только текст и связь с world/entity/step.

5.2.9. llm_call_logs

Колонка Тип Ограничения Назначение
id UUID PK
user_id UUID FK→users.id, nullable Кто инициировал (nullable для системных вызовов)
world_id UUID FK→worlds.id, nullable В каком мире
step_id UUID FK→steps.id, nullable На каком шаге
stage VARCHAR(64) NOT NULL world_builder | world_editor | orchestrator_phase1 | orchestrator_phase2 | orchestrator_phase3_summary | intro_scene | subagent
model VARCHAR(128) NOT NULL Имя модели
request_messages JSONB NOT NULL Полный массив сообщений
request_tools JSONB nullable Описание tools
response_message JSONB NOT NULL Ответ LLM
tool_calls JSONB nullable Извлечённые tool_calls
prompt_tokens INTEGER nullable
completion_tokens INTEGER nullable
latency_ms INTEGER nullable
temperature FLOAT nullable
status VARCHAR(32) NOT NULL ok | timeout | api_error | parse_error | validation_error
error_message TEXT nullable
created_at TIMESTAMPTZ NOT NULL, default now()

Индексы: idx_logs_world_created (world_id, created_at DESC), idx_logs_stage (stage), idx_logs_status (status).

5.2.10. step_tool_calls

Дочерняя таблица steps — детальная запись каждого tool call внутри шага. Нужна для админ-панели и аудита.

Колонка Тип Назначение
id UUID PK
step_id UUID FK→steps.id
tool_name VARCHAR(64) Имя инструмента
arguments JSONB Аргументы вызова
result JSONB Что вернул инструмент
is_success BOOLEAN Успешен ли вызов
executed_at TIMESTAMPTZ

5.3. SQL DDL (фрагмент)

Полный DDL генерируется Alembic, но для справки — ключевые таблицы:

-- pgvector НЕ используется. Векторное хранилище — внешний сервис Qdrant.
-- PostgreSQL хранит только qdrant_point_id (строка-идентификатор точки).

-- users
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    username VARCHAR(64) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    is_admin BOOLEAN NOT NULL DEFAULT false,
    is_active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_login_at TIMESTAMPTZ
);

-- worlds (ключевые поля)
CREATE TABLE worlds (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    preset_id UUID REFERENCES world_presets(id) ON DELETE SET NULL,
    name VARCHAR(255) NOT NULL,
    description TEXT,
    language VARCHAR(8) NOT NULL,
    rules JSONB NOT NULL DEFAULT '[]'::jsonb,
    time_schema JSONB NOT NULL DEFAULT '{"hours_in_day":24,"initial_date":"day_1_hour_8"}'::jsonb,
    schemas JSONB NOT NULL,
    environment_schema JSONB NOT NULL,
    environment JSONB NOT NULL,
    plot_rails JSONB NOT NULL DEFAULT '{"hooks":[],"current_goals":[],"completed_goals":[]}'::jsonb,
    current_time VARCHAR(32) NOT NULL,
    status VARCHAR(32) NOT NULL DEFAULT 'draft',
    intro_scene TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_played_at TIMESTAMPTZ
);
CREATE INDEX idx_worlds_owner_id ON worlds(owner_id);
CREATE INDEX idx_worlds_status ON worlds(status);
CREATE INDEX idx_worlds_last_played_at ON worlds(last_played_at DESC);

-- entities (без вектора; векторы в Qdrant, коллекция `entities`)
CREATE TABLE entities (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    world_id UUID NOT NULL REFERENCES worlds(id) ON DELETE CASCADE,
    entity_type VARCHAR(64) NOT NULL,
    name VARCHAR(255) NOT NULL,
    data JSONB NOT NULL,
    is_in_environment BOOLEAN NOT NULL DEFAULT false,
    qdrant_point_id VARCHAR(64),
    embedding_status VARCHAR(16) NOT NULL DEFAULT 'pending',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_entities_world_id ON entities(world_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_entities_world_type ON entities(world_id, entity_type) WHERE deleted_at IS NULL;
CREATE INDEX idx_entities_embedding_status ON entities(embedding_status) WHERE deleted_at IS NULL AND qdrant_point_id IS NULL;

-- story_entries (без вектора; векторы в Qdrant, коллекция `story_entries`)
CREATE TABLE story_entries (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    world_id UUID NOT NULL REFERENCES worlds(id) ON DELETE CASCADE,
    content TEXT NOT NULL,
    entry_type VARCHAR(64) NOT NULL,
    qdrant_point_id VARCHAR(64),
    embedding_status VARCHAR(16) NOT NULL DEFAULT 'pending',
    metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_story_world_type ON story_entries(world_id, entry_type);
CREATE INDEX idx_story_status ON story_entries(embedding_status) WHERE qdrant_point_id IS NULL;

-- llm_call_logs
CREATE TABLE llm_call_logs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id) ON DELETE SET NULL,
    world_id UUID REFERENCES worlds(id) ON DELETE SET NULL,
    step_id UUID REFERENCES steps(id) ON DELETE SET NULL,
    stage VARCHAR(64) NOT NULL,
    model VARCHAR(128) NOT NULL,
    request_messages JSONB NOT NULL,
    request_tools JSONB,
    response_message JSONB NOT NULL,
    tool_calls JSONB,
    prompt_tokens INTEGER,
    completion_tokens INTEGER,
    latency_ms INTEGER,
    temperature FLOAT,
    status VARCHAR(32) NOT NULL,
    error_message TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_logs_world_created ON llm_call_logs(world_id, created_at DESC);
CREATE INDEX idx_logs_stage ON llm_call_logs(stage);
CREATE INDEX idx_logs_status ON llm_call_logs(status);

5.4. Миграции

  • Инструмент: Alembic.
  • Директория: app/migrations/versions/.
  • Первая миграция: 001_initial_schema.py создаёт все таблицы. pgvector НЕ нужен. Дополнительно к миграции БД — startup-хук app/migrations/init_qdrant.py создаёт коллекции entities и story_entries в Qdrant с правильной размерностью (см. §11.6, авто-проба размерности).
  • Seed-скрипт: app/migrations/seed.py заполняет settings (значения по умолчанию) и встроенные world_presets (минимум 2: фэнтези и sci-fi).
  • Rollback: каждая миграция должна иметь downgrade(). Перед деплоем — обязательный прогон alembic downgrade --sql +1 в staging для проверки.

Порядок создания таблиц (для миграции 001):

  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 <JWT>. Ответы — JSON. Ошибки — в формате {error: {code: string, message: string, details?: object}}.

6.1. Аутентификация и регистрация

POST /api/register

Регистрация нового пользователя. Открывать только если в БД нет ни одного админа — иначе нужен admin-token.

Request body:

{
  "email": "user@example.com",
  "username": "player1",
  "password": "secret123",
  "password_confirm": "secret123"
}

Response 201:

{
  "id": "uuid",
  "email": "user@example.com",
  "username": "player1",
  "is_admin": false,
  "created_at": "2026-06-20T10:00:00Z"
}

Ошибки: 400 email_already_exists, 400 username_already_exists, 400 password_mismatch, 400 weak_password.

POST /api/register/admin

Регистрация администратора. Требует query-параметр ?token=<ADMIN_SETUP_TOKEN>. Если токен не совпадает с admin.setup_token из settings403.

POST /api/auth/login

Request body:

{
  "login": "user@example.com",  // email ИЛИ username
  "password": "secret123"
}

Response 200:

{
  "access_token": "eyJ...",
  "token_type": "bearer",
  "expires_in": 86400,
  "user": { "id": "uuid", "username": "player1", "is_admin": false }
}

Ошибки: 401 invalid_credentials, 403 account_disabled.

POST /api/auth/refresh

Обновление токена. Требует текущий валидный JWT. Возвращает новый.

POST /api/auth/logout

Инвалидирует токен (добавляет в blacklist до истечения). Возвращает 204 No Content.

GET /api/auth/me

Возвращает профиль текущего пользователя.

6.2. Миры (Worlds)

GET /api/worlds

Список миров текущего пользователя. Поддержка pagination.

Query: ?page=1&per_page=20&status=ready&sort=last_played_at.

Response 200:

{
  "items": [
    {
      "id": "uuid",
      "name": "Тёмное подземелье",
      "description": "...",
      "language": "ru",
      "status": "ready",
      "last_played_at": "2026-06-19T20:00:00Z",
      "current_time": "day_3_hour_14_min_30",
      "preview_player_name": "Эрик"
    }
  ],
  "total": 42,
  "page": 1,
  "per_page": 20
}

POST /api/worlds

Создание мира. Запускает асинхронный процесс world_builder (см. раздел 9.1). Возвращает world_id и SSE-канал.

Request body:

{
  "mode": "preset",  // "preset" | "form"
  "preset_id": "uuid",  // если mode=preset
  "form_data": {  // если mode=form
    "setting": "post-apocalyptic underground bunker",
    "rules": ["..."],
    "character_concept": "lone scavenger"
  },
  "name": "Мой бункер",
  "language": "ru",
  "player_name": "Эрик",
  "notes": "Хочу упор на survival horror"
}

Response 202:

{
  "world_id": "uuid",
  "stream_url": "/api/sessions/worlds/uuid/builder/stream"
}

GET /api/worlds/{id}

Полные данные мира (для страницы редактирования/игры). Включает schemas, environment_schema, environment, plot_rails, current_time.

PATCH /api/worlds/{id}

Ручное обновление полей мира (только owner). Используется в world_editor для прямых правок JSON.

Request body: partial World object.

DELETE /api/worlds/{id}

Soft delete: ставит status='archived'. Hard delete — только через админ-панель.

POST /api/worlds/{id}/edit

Запускает world_editor stream. Request body:

{ "instruction": "Добавь игроку меч в инвентарь" }

Response 202: { "stream_url": "/api/sessions/worlds/uuid/editor/stream" }

6.3. Сессии (Sessions) — игровая итерация

GET /api/sessions/worlds/{id}/state

Возвращает текущее состояние для рендера страницы игры: последние N шагов, environment, suggested_actions.

Response 200:

{
  "world": { "id": "uuid", "name": "...", "current_time": "..." },
  "environment": { "player": {...}, "current_location": "...", "plot_rails": {...} },
  "recent_steps": [ { "id": "uuid", "sequence_number": 42, "scene_text": "...", "suggested_actions": [...] } ],
  "next_actions": ["Открыть дверь", "Осмотреть комнату", "Подойти к окну"]
}

POST /api/sessions/worlds/{id}/iterate

Запускает orchestrator (3 фазы). Возвращает SSE URL.

Request body:

{
  "action": "Я открываю дверь мечом",
  "action_source": "custom"  // "custom" | "suggested"
}

Response 202:

{ "stream_url": "/api/sessions/worlds/uuid/iterate/stream", "step_id": "uuid" }

POST /api/sessions/worlds/{id}/retry

Повторная генерация последнего шага (если предыдущая упала). Использует тот же action.

POST /api/sessions/worlds/{id}/rollback

Откат последнего шага. Soft-deletes последний step, восстанавливает предыдущее состояние environment из истории.

6.4. Пресеты (Presets)

GET /api/presets

Список публичных пресетов (status=ready AND is_public=true) + пресеты текущего пользователя.

POST /api/presets (admin only)

Создание нового пресета.

GET /api/presets/{id}

Полные данные пресета.

PATCH /api/presets/{id} (owner/admin)

Редактирование пресета.

DELETE /api/presets/{id} (owner/admin)

Архивация пресета.

6.5. Админка

Все эндпоинты требуют is_admin=true.

GET /api/admin/settings

Возвращает все настройки (кроме секретных значений, которые маскируются).

PATCH /api/admin/settings

Обновление настроек. Request body: { "llm.api_url": "http://...", "llm.model": "..." }.

GET /api/admin/llm-logs

Логи вызовов LLM с фильтрами. Query: ?world_id=&stage=&status=&page=&per_page=&from=&to=.

GET /api/admin/llm-logs/{id}

Полный лог: request_messages, response_message, tool_calls, error_message.

GET /api/admin/users

Список пользователей. PATCH — изменение is_admin, is_active.

GET /api/admin/stats

Сводная статистика: количество пользователей, миров, итераций за период, средний latency LLM.

Тестовые эндпоинты (диагностика подключений)

Эти эндпоинты доступны только админу (is_admin=true). Принимают query params (а не body) — это позволяет проверить настройки до сохранения в settings. Каждый эндпоинт возвращает {ok: bool, elapsed_ms: int, ...детали} и логирует результат в llm_call_logs со stage=test_*.

POST /api/admin/test/llm?api_url=&api_key=&model=

Проверяет базовую связность с LLM. Отправляет промпт "Reply with exactly: OK" (max_tokens=10, temperature=0). Возвращает:

{
  "ok": true,
  "response": "OK",
  "model": "qwen2.5-7b-instruct",
  "elapsed_ms": 412,
  "prompt_tokens": 12,
  "completion_tokens": 2
}

При ошибке: {"ok": false, "error": {"code": "connection_failed", "message": "..."}, "elapsed_ms": 5000}.

POST /api/admin/test/llm-tools?api_url=&api_key=&model=

Проверяет, что LLM корректно вызывает инструменты (function calling). Отправляет промпт "What is 2+2? Use the calc tool." + один инструмент calc(expression). Возвращает:

{
  "ok": true,
  "tool_calls": [{"name": "calc", "arguments": {"expression": "2+2"}}],
  "elapsed_ms": 580,
  "has_tool_calls": true
}

Если has_tool_calls=false — модель не поддерживает function calling в текущей конфигурации; админу показывается warning.

POST /api/admin/test/embeddings?api_url=&api_key=&model=&provider=

Проверяет эмбеддинг-провайдера. Отправляет "hello world" в embeddings API. Возвращает:

{
  "ok": true,
  "dimension": 1536,
  "model": "text-embedding-3-small",
  "first_5_values": [0.0123, -0.0456, 0.0789, -0.0321, 0.0543],
  "elapsed_ms": 142
}

Если provider=offline_hash — endpoint не делает HTTP-запрос, возвращает dimension из локального HashEmbedder.

POST /api/admin/test/embeddings/probe-dimension?api_url=&api_key=&model=&provider=

То же что и /test/embeddings, но возвращает только dimension — используется UI-кнопкой «Авто-проба размерности» (§12.4). Возвращает: {"ok": true, "dimension": 1536, "elapsed_ms": 142}. После этого UI предлагает кнопку «Сохранить 1536 в embeddings.dimension».

POST /api/admin/upload-icon

Принимает multipart/form-data с полем file (PNG/SVG, до 1MB) и опциональным kind (favicon | logo | og_image). Сохраняет файл в ${DATA_DIR}/assets/{kind}_{timestamp}.{ext}, обновляет settings.ui.favicon_url (или ui.logo_url / ui.og_image_url) на относительный URL /static/assets/{kind}_{timestamp}.{ext}. Возвращает:

{
  "ok": true,
  "kind": "favicon",
  "url": "/static/assets/favicon_20260620_142312.png",
  "size_bytes": 12345
}

Статические файлы из ${DATA_DIR}/assets/ раздаются FastAPI через StaticFiles mount на /static/assets.

6.6. Misc

GET /api/health

Без авторизации. { "status": "ok", "db": true, "llm": true, "version": "1.0.0" }.

GET /api/i18n/{lang}

Возвращает JSON с переводами для языка (используется фронтендом для ленивой подгрузки).

6.7. Стандартные коды ошибок

HTTP code Когда
400 validation_error Pydantic-валидация не прошла
400 password_mismatch password != password_confirm
400 weak_password Пароль < 8 символов или в blacklist
401 invalid_credentials Неверный логин/пароль
401 token_expired JWT истёк
401 token_invalid JWT невалиден
403 account_disabled is_active=false
403 not_admin Требуется админ
403 not_owner Не владелец ресурса
404 not_found Ресурс не найден
409 state_conflict Оптимистичная блокировка не прошла
422 world_invalid validate_world() упал
429 rate_limited Превышен лимит (см. NFR §15.2)
500 internal_error Необработанная ошибка
502 llm_unavailable LLM-провайдер недоступен
504 llm_timeout LLM-вызов превысил timeout

7. SSE-протокол

SSE (Server-Sent Events) используется для всех долгих операций: world_builder, world_editor, orchestrator, intro_scene. Все SSE-каналы — односторонние (server→client), для команд от клиента используется REST.

7.1. Заголовки и подключение

GET /api/sessions/worlds/{id}/iterate/stream
Accept: text/event-stream
Authorization: Bearer <JWT>
Cache-Control: no-cache

Каждое событие:

event: <event_type>
data: <json_string>

Двойной \n обязателен. Heartbeat каждые 15 секунд:

event: ping
data: {"ts": "2026-06-20T10:00:00Z"}

7.2. Универсальные события

Эти события могут прийти в любом SSE-канале:

Event Data Назначение
ping {ts} Heartbeat, чтобы соединение не закрылось
error {code, message, details?} Фатальная ошибка, стрим закрывается
warning {code, message} Некритичная проблема, стрим продолжается
progress {phase, step, total_steps?, message?} Прогресс текущей фазы
done {result} Успешное завершение, стрим закрывается

7.3. События orchestrator (итерация сессии)

Event Data Когда
phase_start {phase: 1|2|3, name} Начало фазы
phase_end {phase, duration_ms} Конец фазы
tool_call {tool, arguments, result?, is_success} LLM вызвала инструмент
llm_call_start {stage, model} Начало LLM-вызова
llm_call_end {stage, latency_ms, tokens} Конец LLM-вызова
scene_chunk {text} Streaming-чанк текста из Phase 2
scene_complete {text, delta_time} Полный текст сцены
suggested_actions {actions: [...]} 1-3 следующих действия
trigger_fired {trigger_id, event_type, summary} Сработал отложенный триггер
summary_generated {summary_id, message_range} Сгенерирован summary
iteration_complete {step_id, sequence_number} Полное завершение

7.4. События world_builder

Event Data
step {step: "generating_schema", message: "..."}
world_schema_generated {schemas, environment_schema}
environment_generated {environment}
entities_generated {entities: [...]}
intro_scene_chunk {text}
intro_scene_complete {text, suggested_actions}

7.5. События world_editor

Event Data
llm_thinking {}
clarification {question, options?}
change_proposed {diff: [{path, op, old, new}]}
comment {text}
apply_changes {}
discard_changes {}

7.6. Reconnect-стратегия

  • Фронтенд использует EventSource (нативный) с автоконнектом.
  • При reconnect клиент шлёт Last-Event-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-объект с одинаковой структурой:

{
  "ok": true,
  "data": { ... },
  "message": "человекочитаемое описание для LLM"
}

или в случае ошибки:

{
  "ok": false,
  "error": {
    "code": "validation_error",
    "message": "player.stats.health must be >= 0, got -5"
  }
}

Этот формат возвращается LLM как tool_result сообщение. LLM видит и message (для самокоррекции), и data (для использования в следующих шагах).

8.2. Game tools — детальные сигнатуры

8.2.1. entity_create

Создаёт новую сущность в мире.

{
  "name": "entity_create",
  "description": "Создаёт новую сущность в текущем мире. Тип должен существовать в world.schemas. data должна соответствовать schema этого типа.",
  "parameters": {
    "type": "object",
    "required": ["entity_type", "name", "data"],
    "properties": {
      "entity_type": {
        "type": "string",
        "description": "Тип сущности из world.schemas (character, item, location, faction, ...)"
      },
      "name": {
        "type": "string",
        "description": "Имя/название сущности (уникально в пределах (world_id, entity_type))"
      },
      "data": {
        "type": "object",
        "description": "Полные данные сущности по schema"
      },
      "add_to_environment": {
        "type": "boolean",
        "default": false,
        "description": "Добавить ли сущность в environment (для быстрого доступа LLM)"
      }
    }
  }
}

Возвращает:

{ "ok": true, "data": { "entity_id": "uuid" }, "message": "Создан character 'Элара'" }

Ошибки: unknown_entity_type, name_conflict, schema_violation, world_not_found.

8.2.2. entity_get

Получает сущность по ID или по (entity_type, name).

{
  "name": "entity_get",
  "parameters": {
    "required": ["query"],
    "properties": {
      "query": {
        "oneOf": [
          { "type": "string", "description": "entity_id (UUID)" },
          {
            "type": "object",
            "properties": {
              "entity_type": { "type": "string" },
              "name": { "type": "string" }
            },
            "required": ["entity_type", "name"]
          }
        ]
      }
    }
  }
}

8.2.3. entity_list

Список сущностей с фильтром.

{
  "name": "entity_list",
  "parameters": {
    "properties": {
      "entity_type": { "type": "string", "description": "Фильтр по типу. Если не указан — все типы" },
      "in_environment_only": { "type": "boolean", "default": false },
      "name_contains": { "type": "string" },
      "limit": { "type": "integer", "default": 50, "max": 200 }
    }
  }
}

8.2.4. entity_update

Обновляет поля сущности через JSON-patch.

{
  "name": "entity_update",
  "parameters": {
    "required": ["entity_id", "patch"],
    "properties": {
      "entity_id": { "type": "string" },
      "patch": {
        "type": "object",
        "description": "JSON-patch: {field_path: new_value} или {field_path: {op: 'inc', by: N}}",
        "example": { "stats.health": { "op": "inc", "by": -10 }, "description": "Ранен" }
      }
    }
  }
}

8.2.5. entity_delete

Soft delete. Помечает deleted_at=now().

{
  "name": "entity_delete",
  "parameters": {
    "required": ["entity_id"],
    "properties": {
      "entity_id": { "type": "string" },
      "reason": { "type": "string", "description": "Почему удаляется (для лога)" }
    }
  }
}

8.2.6. env_update

Ключевой инструмент — мутирует environment через валидируемый patch.

{
  "name": "env_update",
  "description": "Применяет JSON-patch к environment. Patch проходит через state_validator. Если валидация провалилась — patch не применяется, LLM получает ошибку и может попробовать ещё раз.",
  "parameters": {
    "required": ["patch"],
    "properties": {
      "patch": {
        "type": "object",
        "description": "Карта field_path -> new_value | {op, by}. Поддерживаемые op: 'inc', 'dec', 'set', 'append', 'remove'.",
        "example": {
          "player.stats.health": { "op": "inc", "by": -10 },
          "player.stats.mana": { "op": "inc", "by": -5 },
          "player.inventory": { "op": "append", "value": { "item_id": "sword_01", "qty": 1 } }
        }
      }
    }
  }
}

Возвращает:

{
  "ok": true,
  "data": { "applied_paths": ["player.stats.health", "player.stats.mana"] },
  "message": "Environment обновлён"
}

8.2.7. env_get

Возвращает текущее значение поля environment (или весь environment если path не указан).

{
  "name": "env_get",
  "parameters": {
    "properties": {
      "path": { "type": "string", "description": "Например 'player.stats' или 'plot_rails.current_goals'" }
    }
  }
}

8.2.8. update_plot_rails

Специализированный инструмент для управления сюжетными рельсами.

{
  "name": "update_plot_rails",
  "parameters": {
    "required": ["operation"],
    "properties": {
      "operation": {
        "type": "string",
        "enum": ["add_hook", "remove_hook", "add_goal", "remove_goal", "complete_goal"]
      },
      "value": { "type": "string", "description": "Текст хука/цели" },
      "index": { "type": "integer", "description": "Для remove_* операций" }
    }
  }
}

8.2.9. rag_query

Семантический поиск по story_entries и entities через Qdrant. Сначала векторы запроса и документов сравниваются в Qdrant (с payload-фильтром по world_id), затем полные данные подтягиваются из PostgreSQL по IDs (см. §11.1.4).

{
  "name": "rag_query",
  "description": "Semantic search over entities and story entries. Use when you need to recall past details, NPC names, world facts, or find an entity by description. Do NOT try to recall from memory — you may hallucinate.",
  "parameters": {
    "required": ["query"],
    "properties": {
      "query": { "type": "string", "description": "Natural-language search query." },
      "limit": { "type": "integer", "default": 5, "max": 20 },
      "filter_type": { "type": "string", "enum": ["all", "entities", "story_entries"], "default": "all" },
      "min_score": { "type": "number", "default": 0.7, "description": "Minimum cosine similarity (0..1)." }
    }
  }
}

Возвращает:

{
  "ok": true,
  "data": {
    "results": [
      { "type": "entity", "id": "uuid", "score": 0.89, "content": { "entity_type": "character", "name": "...", "data": {...} } },
      { "type": "story_entry", "id": "uuid", "score": 0.82, "content": { "text": "Игрок убил дракона в день 3" } }
    ]
  }
}

8.2.10. rag_add

Добавляет факт в story_entries и индексирует его в Qdrant-коллекции story_entries (см. §11.1.5). Если embeddings API недоступен — запись сохраняется с embedding_status='pending', фоновый индексатор досчитает вектор позже.

{
  "name": "rag_add",
  "description": "Persist a fact/event as a story entry and index it for semantic search. Use when the player learns a new persistent fact (NPC secret, world lore, quest outcome) that should be recallable later via rag_query.",
  "parameters": {
    "required": ["content", "entry_type"],
    "properties": {
      "content": { "type": "string", "description": "Fact text. Will be truncated to 4000 chars before embedding." },
      "entry_type": { "type": "string", "enum": ["fact", "event", "relationship", "secret"] },
      "metadata": { "type": "object", "description": "Links: entity_id, step_id, etc." }
    }
  }
}

8.2.11. schedule_trigger

Планирует отложенное событие.

{
  "name": "schedule_trigger",
  "description": "Планирует событие на игровое время. Сработает автоматически когда current_time >= fire_at.",
  "parameters": {
    "required": ["fire_at", "event_type", "payload"],
    "properties": {
      "fire_at": { "type": "string", "description": "Формат: [year_Y_]day_D_hour_H[_min_M]. Пример: 'day_5_hour_12'" },
      "event_type": { "type": "string", "enum": ["spawn_enemy", "weather_change", "quest_update", "npc_action", "custom"] },
      "payload": { "type": "object", "description": "Данные события. Структура зависит от event_type." }
    }
  }
}

8.2.12. advance_time

Принудительное продвижение времени (альтернатива — submit_step с полем delta_time).

{
  "name": "advance_time",
  "parameters": {
    "required": ["delta"],
    "properties": {
      "delta": { "type": "string", "description": "Формат: [year_Y][days_D][hours_H][min_M]. Пример: 'hours_2_min_30'" }
    }
  }
}

8.2.13. calc

Калькулятор для боевых и механических вычислений. Не позволяет LLM ошибиться в арифметике.

{
  "name": "calc",
  "description": "Вычисляет математическое выражение. Используй для боёв, бросков кубиков, расчёта урона.",
  "parameters": {
    "required": ["expression"],
    "properties": {
      "expression": {
        "type": "string",
        "description": "Выражение в безопасном DSL. Поддерживаются +, -, *, /, %, d (бросок кубика: 2d6+3), min(), max(), round()",
        "example": "max(1, 2d6+3 - enemy.armor)"
      },
      "variables": {
        "type": "object",
        "description": "Контекст для подстановки переменных",
        "example": { "enemy.armor": 5 }
      }
    }
  }
}

Возвращает: { "ok": true, "data": { "result": 8, "rolls": [3, 5], "trace": "max(1, 8-5)=3" } }

8.2.14. random_choice

Детерминированный (с seed) выбор из вариантов. Seed = hash(world_id + step_id + choice_index) для воспроизводимости.

{
  "name": "random_choice",
  "parameters": {
    "required": ["options"],
    "properties": {
      "options": { "type": "array", "items": {}, "minItems": 2 },
      "weights": { "type": "array", "items": { "type": "number" } }
    }
  }
}

8.2.15. run_subagent

Запускает вложенный LLM-вызов для офэкранных действий (Phase 3.1).

{
  "name": "run_subagent",
  "description": "Запускает вложенный LLM-вызов с собственным промптом для офэкранных действий (например, что происходит в соседней комнате пока игрок здесь).",
  "parameters": {
    "required": ["task", "tools"],
    "properties": {
      "task": { "type": "string", "description": "Описание задачи для subagent" },
      "tools": {
        "type": "array",
        "items": { "type": "string" },
        "description": "Список имён инструментов, доступных subagent"
      },
      "context": {
        "type": "object",
        "description": "Дополнительный контекст (entity_id, location, ...)"
      },
      "max_iterations": { "type": "integer", "default": 5, "max": 10 }
    }
  }
}

8.2.16. submit_plan (Phase 1 завершение)

Завершает Phase 1, передаёт план действий в Phase 2.

{
  "name": "submit_plan",
  "description": "Завершает Phase 1 планирования. Передаёт план и краткую сводку действий в Phase 2 (writer).",
  "parameters": {
    "required": ["plan", "summary"],
    "properties": {
      "plan": {
        "type": "string",
        "description": "Что произошло в этой итерации (для writer)"
      },
      "summary": {
        "type": "array",
        "items": { "type": "object" },
        "description": "Краткая сводка вызванных инструментов: [{tool, result_summary}]",
        "example": [
          { "tool": "entity_create", "result_summary": "Создан враг 'Гоблин'" },
          { "tool": "env_update", "result_summary": "player.stats.health -10" }
        ]
      },
      "offscreen_events": {
        "type": "array",
        "items": { "type": "string" },
        "description": "Заэкранные события для subagent в Phase 3.1"
      }
    }
  }
}

8.2.17. submit_step (Phase 2 завершение)

Финальный инструмент Phase 2 — writer возвращает сцену.

{
  "name": "submit_step",
  "description": "Завершает Phase 2. Writer возвращает финальный нарратив и дельту времени.",
  "parameters": {
    "required": ["scene_text", "delta_time"],
    "properties": {
      "scene_text": {
        "type": "string",
        "description": "Нарратив итерации. Минимум 100 символов, максимум 4000."
      },
      "delta_time": {
        "type": "string",
        "description": "Сколько игрового времени заняла итерация. Формат: [year_Y][days_D][hours_H][min_M]."
      }
    }
  }
}

8.2.18. suggest_actions (Phase 3.2 завершение)

Генерирует 1-3 следующих действия для игрока.

{
  "name": "suggest_actions",
  "parameters": {
    "required": ["actions"],
    "properties": {
      "actions": {
        "type": "array",
        "items": { "type": "string" },
        "minItems": 1,
        "maxItems": 3,
        "description": "1-3 коротких действия на языке мира"
      }
    }
  }
}

8.3. Interaction tools

8.3.1. ask_user

Запрос уточнения у игрока в world_builder / world_editor.

{
  "name": "ask_user",
  "parameters": {
    "required": ["question"],
    "properties": {
      "question": { "type": "string" },
      "options": {
        "type": "array",
        "items": { "type": "string" },
        "description": "Опциональные варианты ответа"
      },
      "allow_free_text": { "type": "boolean", "default": true }
    }
  }
}

Блокирует поток до ответа игрока (через SSE clarification event + REST /api/sessions/.../answer).

8.3.2. propose_changes (world_editor)

Предлагает изменения игроку на accept/reject.

{
  "name": "propose_changes",
  "parameters": {
    "required": ["diff"],
    "properties": {
      "diff": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "path": { "type": "string", "example": "schemas[1].properties[2].verbose" },
            "op": { "type": "string", "enum": ["add", "remove", "replace"] },
            "old": {},
            "new": {}
          }
        }
      },
      "comment": { "type": "string", "description": "Почему эти изменения" }
    }
  }
}

8.3.3. comment_to_user

Просто текстовый комментарий в чат (не требует ответа).

{
  "name": "comment_to_user",
  "parameters": {
    "required": ["text"],
    "properties": { "text": { "type": "string" } }
  }
}

8.4. Schema tools (для world_editor)

8.4.1. schema_add_type

Добавляет новый тип сущности в world.schemas.

{
  "name": "schema_add_type",
  "parameters": {
    "required": ["type", "verbose", "plural", "properties"],
    "properties": {
      "type": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
      "verbose": { "type": "string" },
      "plural": { "type": "string" },
      "properties": { "type": "array", "items": { /* property definition */ } }
    }
  }
}

8.4.2. schema_add_field, schema_remove_field, schema_modify_field

Аналогично — для редактирования properties существующих типов.

8.5. Реестр инструментов

Все инструменты регистрируются в app/engine/tools/registry.py:

from app.engine.tools.base import Tool, ToolContext, ToolResult

class ToolRegistry:
    def __init__(self):
        self._tools: dict[str, Tool] = {}

    def register(self, tool: Tool) -> None:
        self._tools[tool.name] = tool

    def get(self, name: str) -> Tool | None:
        return self._tools.get(name)

    def list_for_stage(self, stage: str) -> list[Tool]:
        """Возвращает инструменты, доступные на данной стадии."""
        ...

    def to_openai_format(self, stage: str) -> list[dict]:
        """Сериализует в OpenAI tools format."""
        ...

Доступность инструментов по стадиям:

Stage Доступные инструменты
world_builder ask_user, comment_to_user, schema_add_type, schema_add_field, entity_create, env_update, submit_plan
world_editor ask_user, comment_to_user, propose_changes, schema_*, entity_*, env_update, rag_query
orchestrator_phase1 Все game tools, кроме submit_step и suggest_actions
orchestrator_phase2 submit_step (единственный)
orchestrator_phase3_summary submit_summary
orchestrator_phase3_suggest suggest_actions (единственный)
intro_scene submit_step, suggest_actions
subagent entity_*, env_get, env_update, rag_query

8.6. Контракт выполнения

Каждый tool вызывается через ToolRegistry.execute():

async def execute(
    self,
    name: str,
    arguments: dict,
    ctx: ToolContext,
) -> ToolResult:
    """
    ctx содержит: world, session, user_id, step_id, stage, sse_emitter.
    Возвращает ToolResult с ok/data/message или ok=false/error.
    Логирует вызов в step_tool_calls.
    Эмитит SSE событие tool_call.
    """

9. Потоки (flows) с sequence-диаграммами

В системе семь ключевых потоков. Каждый поток имеет свой system-промпт, свой набор инструментов и свой SSE-протокол. Каждый поток изолирован — сообщения одного потока не видны другому, чтобы предотвратить галлюцинации.

9.1. Поток создания мира (world_builder)

Запускается при POST /api/worlds. Создаёт новый мир из пресета или формы, генерирует схемы, environment, начальные сущности и вступительную сцену.

Шаги:

  1. Получение шаблона мира (built-in / опубликованный / form).
  2. Игрок заполняет: название, язык, своего персонажа, заметки.
  3. Первоначальная генерация мира (LLM генерирует schemas, environment_schema, environment с player).
  4. Редактирование (через world_editor flow).
  5. Догенерация начального состояния (локации, персонажи, предметы, цели).
  6. Генерация вступительной сцены + 1-3 действий.
  7. Игрок нажимает "Начать игру" → переход на страницу мира.
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 предлагает изменения, игрок принимает/отклоняет.

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-диаграмма

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):

triggers = await db.execute(
    select(DeferredTrigger)
    .where(
        DeferredTrigger.world_id == world_id,
        DeferredTrigger.is_fired == False,
        DeferredTrigger.fire_at <= world.current_time,
    )
)
for trigger in triggers:
    subagent_result = await run_subagent(
        task=f"Process deferred trigger: {trigger.event_type}",
        context=trigger.payload,
        tools=["entity_create", "entity_update", "env_update", "rag_add"],
        max_iterations=5,
    )
    # subagent_result.offscreen_summary добавляется к scene_text
    step.scene_text += "\n\n" + subagent_result.offscreen_summary
    trigger.is_fired = True
    trigger.fired_at = now()
    await db.commit()

3.2 Summary (если нужно):

recent_steps = await get_recent_steps(world_id, limit=compression_threshold + 1)
if len(recent_steps) > compression_threshold:
    old_messages = [s.to_llm_message() for s in recent_steps[guaranteed:]]
    summary = await llm.complete(
        prompt=get_prompt("summary", world.language),
        messages=old_messages,
        temperature=0.3,
    )
    await db.insert(StoryEntry(
        world_id=world_id,
        content=summary,
        entry_type="event",
        metadata={"type": "summary", "step_range": [first_seq, last_seq]},
    ))

3.3 Suggest actions:

suggestions = await llm.complete(
    prompt=get_prompt("suggest_actions", world.language),
    messages=[recent_scene_message],
    tools=[suggest_actions_tool],
    temperature=0.8,
)
step.suggested_actions = suggestions.actions

9.4. Поток инициализации сервера

Запускается при старте приложения (lifespan handler в FastAPI).

sequenceDiagram
    participant Uvicorn
    participant App as FastAPI
    participant DB as PostgreSQL
    participant Settings as settings_service

    Uvicorn->>App: startup
    App->>DB: CREATE EXTENSION IF NOT EXISTS vector
    App->>DB: alembic upgrade head
    App->>Settings: load_defaults_from_env()
    Settings->>DB: UPSERT settings FROM .env
    App->>Settings: ensure_admin_setup_token()
    Settings-->>App: token (from DB or generated)
    App->>App: print("Admin setup URL: /register/admin?token=...")
    App->>DB: SELECT COUNT(*) FROM users WHERE is_admin=true
    alt no admins
        App->>App: print("WARN: no admins yet, use setup URL")
    end

9.5. Поток создания администратора

При каждом запуске сервер выводит в лог URL вида /register/admin?token=<ADMIN_SETUP_TOKEN>. Токен берётся из .env (если задан) или генерируется случайный и сохраняется в settings.

sequenceDiagram
    participant Admin as Будущий админ
    participant FE as Frontend
    participant API as FastAPI

    Admin->>FE: Открывает /register/admin?token=XXX
    FE->>API: POST /api/register/admin {token, email, username, password}
    API->>API: validate token == settings['admin.setup_token']
    alt token valid
        API->>API: create user (is_admin=true)
        API-->>FE: 201 {user}
        FE->>Admin: Редирект на /login
    else token invalid
        API-->>FE: 403 invalid_admin_token
    end

Edge-cases:

  • Если в БД уже есть админ → регистрация по admin-URL блокируется (403 admin_already_exists), даже с верным токеном.
  • Токен ротируется каждые 24 часа (cron task).

9.6. Регистрация пользователя

Обычная регистрация. Доступна только если в БД уже есть хотя бы один админ (иначе система "закрыта" до создания первого админа).

Валидации:

  • email: RFC-совместимый, не длиннее 255 символов, уникальный.
  • username: 3-64 символа, [a-zA-Z0-9_], уникальный.
  • password: ≥ 8 символов, минимум 1 буква и 1 цифра, не в blacklist (top-1000 утечек).
  • password_confirm: должен совпадать с password.

9.7. Вход в систему

Логин по email ИЛИ username (автоопределение по наличию @). JWT-токен с expires_in=86400 (24 часа). Refresh-токен с expires_in=604800 (7 дней).

sequenceDiagram
    participant U as Пользователь
    participant FE as Frontend
    participant API as FastAPI
    participant DB as PostgreSQL

    U->>FE: Вводит login + password
    FE->>API: POST /api/auth/login {login, password}
    API->>DB: SELECT user WHERE email=$1 OR username=$1
    alt user found
        API->>API: verify bcrypt(password, user.password_hash)
        alt password correct
            API->>API: generate JWT (sub=user_id, exp=now+24h)
            API->>DB: UPDATE user.last_login_at=now()
            API-->>FE: 200 {access_token, refresh_token, user}
            FE->>FE: store token in authStore
            FE->>U: redirect to /worlds
        else password wrong
            API-->>FE: 401 invalid_credentials
        end
    else user not found
        API-->>FE: 401 invalid_credentials
    end

Anti-enumeration: ответ на "неверный пароль" и "пользователь не найден" одинаковый (401 invalid_credentials), чтобы не давать информации для перебора.


10. Промпт-шаблоны и контекстный менеджер

10.1. Структура промптов

Все системные промпты лежат в app/prompts/ как Python-строки с str.format() интерполяцией. Единственная точка доступа — функция get_prompt(stage, language):

# app/prompts/__init__.py
from app.prompts.registry import get_prompt

system_prompt = get_prompt("orchestrator_phase1", "ru").format(
    world_name=world.name,
    rules="\n".join(f"- {r}" for r in world.rules),
    schemas_summary=summarize_schemas(world.schemas),
    environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
    current_time=world.current_time,
    plot_rails_json=json.dumps(world.plot_rails, ensure_ascii=False, indent=2),
)

Структура директории:

app/prompts/
├── __init__.py
├── registry.py          # get_prompt(stage, language)
├── stages/
│   ├── world_builder_schema.py
│   ├── world_builder_env.py
│   ├── world_builder_entities.py
│   ├── world_editor.py
│   ├── orchestrator_phase1.py
│   ├── orchestrator_phase2.py
│   ├── orchestrator_phase3_summary.py
│   ├── orchestrator_phase3_suggest.py
│   ├── intro_scene.py
│   ├── subagent.py
│   └── summary.py
└── locales/
    ├── en.py
    └── ru.py

Соглашение: каждый файл stages/*.py экспортирует dict PROMPTS = {"en": "...", "ru": "..."}. registry.py собирает их в один большой dict по ключу stage.

⚠️ Языковой правило для LLM-промптов (критично): Все промпты и инструкции для LLM (system-сообщения, описания инструментов в JSON-schema, runtime-инструкции, сводные блоки типа rules / schemas_summary / environment_json) должны быть на английском — даже если world.language = 'ru' и игровой нарратив генерируется на русском. Английские промпты дают существенно более высокое качество для современных LLM (особенно локальных 7B-32B моделей): меньше галлюцинаций, точнее tool-calling, лучше следование формату.

Разделение:

  • Промпт (внутренний) — английский. Хранится в app/prompts/stages/*.py под ключом "en". Ключ "ru" в PROMPTS оставлен только для legacy-сценариев и в новой разработке НЕ используется.
  • Нарратив (внешний, для игрока) — язык мира world.language. Phase 2 writer инструктируется английским промптом, но генерирует текст на языке мира («Write the scene in {language}»).
  • UI-строки фронтенда — язык интерфейса пользователя (uiStore.language), через i18n.

Это правило обязательно для всех stage-промптов. Если в существующем промпте есть русский — это баг, нужно перевести.

10.2. Шаблон system-промпта для orchestrator_phase1

# app/prompts/stages/orchestrator_phase1.py
PROMPTS = {
    "ru": """Ты — Game Master (GM) текстовой ролевой игры в мире "{world_name}".  # LEGACY: не используется, см. языковое правило §10.1
...
""",
    "en": """You are the Game Master (GM) of a text RPG in the world "{world_name}".

# Your responsibilities
1. Evaluate the player's action and decide what happened mechanically.
2. Call tools for ANY state change in the world.
3. Do NOT write free narrative — the writer will do that in Phase 2.
4. End Phase 1 by calling submit_plan with the plan and action summary.

# World rules
{rules}

# Entity schemas
{schemas_summary}

# Current environment
{environment_json}

# Plot rails
{plot_rails_json}

# Current time
{current_time}

# Available tools
You can call: entity_create, entity_get, entity_list, entity_update, entity_delete,
env_update, env_get, rag_query, rag_add, schedule_trigger, advance_time, calc, random_choice,
run_subagent, update_plot_rails, submit_plan.

# Hard rules
- ANY state change goes through a tool call. Do NOT write "you took damage" in the text.
- After each tool call you receive a tool_result. Check ok=true.
- If ok=false — fix the arguments and try again.
- Use calc for dice rolls and arithmetic. Do NOT compute in your head.
- After max {max_substeps} steps you MUST call submit_plan.
"""
}

10.3. Контекстный менеджер истории

LLM-контекст ограничен (8K32K токенов в зависимости от модели). В долгих сессиях нельзя передавать всю историю. Стратегия: последние N сообщений + опциональный summary.

Логика (реализована в app/engine/context.py):

async def build_context(
    world: World,
    step: Step,
    recent_steps: list[Step],
    settings: Settings,
) -> list[dict]:
    """
    Возвращает массив messages для LLM.
    """
    guaranteed = settings["context.guaranteed_messages"]  # default 10
    threshold = settings["context.compression_threshold_messages"]  # default 20

    messages: list[dict] = []

    # 1. System prompt
    messages.append({"role": "system", "content": system_prompt})

    # 2. Если история длинная — добавляем summary в начале
    if len(recent_steps) > threshold:
        summary = await get_latest_summary(world.id)
        if summary:
            messages.append({
                "role": "system",
                "content": f"Сводка прошлых событий:\n{summary.content}"
            })
        # Берём только последние guaranteed шагей
        recent_steps = recent_steps[-guaranteed:]

    # 3. Последние шаги как user/assistant messages
    for s in recent_steps:
        messages.append({"role": "user", "content": s.player_action})
        messages.append({"role": "assistant", "content": s.scene_text})

    # 4. Текущее действие игрока
    messages.append({"role": "user", "content": step.player_action})

    return messages

Когда генерируется summary:

  • При каждом Phase 3 orchestrator проверяет: len(recent_steps) > threshold (с учётом уже существующих summaries).
  • Если да — вызывает LLM с prompt(summary) на сообщения [guaranteed:last].
  • Создаёт StoryEntry с entry_type="event", metadata={"type": "summary", "step_range": [...]}.
  • Старые сообщения (кроме guaranteed) не удаляются из БД — они просто не попадают в контекст LLM. Полная история доступна через API.

Параметры LLM для summary: temperature=0.3 (низкая — для точности), max_tokens=1024.

10.4. Параметры LLM по стадиям

Stage temperature top_p max_tokens Stream
world_builder_schema 0.5 0.9 4096 no
world_builder_env 0.6 0.9 2048 no
world_builder_entities 0.7 0.9 2048 no
world_editor 0.5 0.9 2048 no
orchestrator_phase1 0.7 0.9 2048 no
orchestrator_phase2 0.85 0.95 2048 yes (scene_chunk)
orchestrator_phase3_summary 0.3 0.9 1024 no
orchestrator_phase3_suggest 0.8 0.95 512 no
intro_scene 0.85 0.95 2048 yes
subagent 0.6 0.9 2048 no
summary 0.3 0.9 1024 no

11. RAG-подсистема и валидация состояния

11.1. RAG через Qdrant

Архитектура RAG-подсистемы построена на разделении хранения: PostgreSQL хранит текст и метаданные, Qdrant — только векторы. Это даёт несколько преимуществ:

  • Масштабируемость: Qdrant оптимизирован для ANN-поиска (HNSW) и не нагружает PostgreSQL векторными индексами.
  • Независимость: можно менять embedding-модель без миграции реляционной схемы — достаточно пересоздать коллекцию и переиндексировать.
  • Изоляция миров: каждая точка в Qdrant имеет payload {world_id, entity_type, deleted_at, ...}; фильтр по world_id гарантирует, что поиск в одном мире никогда не вернёт результаты другого.

11.1.1. Топология коллекций

Две коллекции в одном Qdrant-инстансе (НЕ одна коллекция на мир — это усложнило бы администрирование):

Коллекция Размерность Distance Payload-поля Назначение
entities из settings.embeddings.dimension Cosine world_id, entity_id, entity_type, name, deleted Векторы сущностей (character, item, location, ...)
story_entries из settings.embeddings.dimension Cosine world_id, entry_id, entry_type, step_id, created_at Векторы сюжетных записей (fact, event, summary, ...)

ID точки в Qdrant = str(uuid) соответствующей записи в PostgreSQL. В PostgreSQL колонка qdrant_point_id хранит тот же UUID — это позволяет восстановить связь при переиндексации.

Почему одна коллекция на тип, а не на мир? Создание коллекции под каждый мир потребует N коллекций (по числу миров), что усложнит администрирование и резервное копирование. Фильтр по world_id в payload даёт ту же изоляцию с минимальным оверхедом — Qdrant индексирует payload-поля отдельно от векторов (payload index), поэтому фильтрация по world_id практически бесплатна.

11.1.2. Создание коллекций (startup-хук)

app/migrations/init_qdrant.py запускается при старте приложения (после alembic upgrade head). Создаёт коллекции если их нет, и обязательные payload-индексы для быстрых фильтров:

# app/migrations/init_qdrant.py
from qdrant_client import AsyncQdrantClient
from qdrant_client.http.models import Distance, VectorParams, PayloadSchemaType

async def init_qdrant_collections(dimension: int) -> None:
    client = AsyncQdrantClient(url=settings.QDRANT_URL, api_key=settings.QDRANT_API_KEY)
    existing = {c.name for c in (await client.get_collections()).collections}

    for name in ("entities", "story_entries"):
        if name in existing:
            continue
        await client.create_collection(
            collection_name=name,
            vectors_config=VectorParams(size=dimension, distance=Distance.COSINE),
        )
        await client.create_payload_index(name, "world_id", PayloadSchemaType.KEYWORD)
        if name == "entities":
            await client.create_payload_index(name, "entity_type", PayloadSchemaType.KEYWORD)
            await client.create_payload_index(name, "deleted", PayloadSchemaType.BOOL)
        else:
            await client.create_payload_index(name, "entry_type", PayloadSchemaType.KEYWORD)
            await client.create_payload_index(name, "created_at", PayloadSchemaType.INTEGER)

11.1.3. Интерфейс Embedder

# app/core/embeddings.py
from typing import Protocol
import httpx

class Embedder(Protocol):
    async def embed(self, texts: list[str]) -> list[list[float]]: ...
    @property
    def dimension(self) -> int: ...

class HashEmbedder:
    """Offline-эмбеддер для dev/test. Bag-of-words + hash projection."""
    def __init__(self, dimension: int = 256):
        self._dim = dimension
    async def embed(self, texts: list[str]) -> list[list[float]]:
        return [self._hash_project(t) for t in texts]
    @property
    def dimension(self) -> int:
        return self._dim

class OpenAIEmbedder:
    """OpenAI-compatible embeddings API."""
    def __init__(self, api_url: str, api_key: str, model: str, dimension: int, timeout: float = 30.0):
        self._api_url = api_url.rstrip("/")
        self._api_key = api_key
        self._model = model
        self._dim = dimension
        self._timeout = timeout
    async def embed(self, texts: list[str]) -> list[list[float]]:
        async with httpx.AsyncClient(timeout=self._timeout) as client:
            resp = await client.post(
                f"{self._api_url}/embeddings",
                headers={"Authorization": f"Bearer {self._api_key}"},
                json={"model": self._model, "input": texts},
            )
            resp.raise_for_status()
            data = resp.json()
            return [d["embedding"] for d in sorted(data["data"], key=lambda x: x["index"])]
    @property
    def dimension(self) -> int:
        return self._dim

11.1.4. Реализация rag_query (двухстадийный retrieval)

Сначала ищем в Qdrant → получаем IDs + scores → затем по IDs подтягиваем полные данные из PostgreSQL. Это держит payload Qdrant маленьким, а полный текст — в реляционной БД.

# app/core/rag.py
from uuid import UUID
from qdrant_client import AsyncQdrantClient
from qdrant_client.http.models import Filter, FieldCondition, MatchValue

async def rag_query(
    world_id: UUID,
    query: str,
    limit: int = 5,
    filter_type: str = "all",
    min_score: float = 0.7,
) -> list[dict]:
    """Семантический поиск по entities + story_entries через Qdrant."""
    embedder = get_embedder()
    try:
        query_vec = (await embedder.embed([query]))[0]
    except Exception as e:
        logger.warning("rag_query_embed_failed", error=str(e))
        return []  # embedder недоступен — возвращаем пусто, не роняем итерацию

    qdrant: AsyncQdrantClient = get_qdrant_client()
    world_filter = FieldCondition(key="world_id", match=MatchValue(value=str(world_id)))

    results: list[dict] = []

    if filter_type in ("all", "entities"):
        ents = await qdrant.search(
            collection_name="entities",
            query_vector=query_vec,
            query_filter=Filter(must=[world_filter,
                                       FieldCondition(key="deleted", match=MatchValue(value=False))]),
            limit=limit,
            score_threshold=min_score,
            with_payload=True,
        )
        for p in ents:
            results.append({"type": "entity", "id": p.payload["entity_id"],
                            "score": p.score, "name": p.payload.get("name"),
                            "entity_type": p.payload.get("entity_type")})

    if filter_type in ("all", "story_entries"):
        sts = await qdrant.search(
            collection_name="story_entries",
            query_vector=query_vec,
            query_filter=Filter(must=[world_filter]),
            limit=limit,
            score_threshold=min_score,
            with_payload=True,
        )
        for p in sts:
            results.append({"type": "story_entry", "id": p.payload["entry_id"],
                            "score": p.score, "entry_type": p.payload.get("entry_type")})

    # Sort by score desc, truncate
    results.sort(key=lambda r: r["score"], reverse=True)
    top = results[:limit]

    # Stage 2: fetch full data from PostgreSQL by IDs
    return await _hydrate_from_postgres(top, world_id)

async def _hydrate_from_postgres(items: list[dict], world_id: UUID) -> list[dict]:
    """Подтягивает полные данные entities.data и story_entries.content из PostgreSQL."""
    entity_ids = [UUID(i["id"]) for i in items if i["type"] == "entity"]
    story_ids = [UUID(i["id"]) for i in items if i["type"] == "story_entry"]

    entities_map: dict[UUID, dict] = {}
    stories_map: dict[UUID, dict] = {}
    if entity_ids:
        rows = await db.execute(select(Entity).where(Entity.id.in_(entity_ids)))
        entities_map = {r.id: {"name": r.name, "entity_type": r.entity_type, "data": r.data} for r in rows.scalars()}
    if story_ids:
        rows = await db.execute(select(StoryEntry).where(StoryEntry.id.in_(story_ids)))
        stories_map = {r.id: {"content": r.content, "entry_type": r.entry_type, "metadata": r.metadata} for r in rows.scalars()}

    out = []
    for i in items:
        if i["type"] == "entity":
            full = entities_map.get(UUID(i["id"]))
            if full:
                out.append({**i, "content": full})
        else:
            full = stories_map.get(UUID(i["id"]))
            if full:
                out.append({**i, "content": full})
    return out

11.1.5. Реализация rag_add

async def rag_add(
    world_id: UUID, content: str, entry_type: str, metadata: dict | None = None,
) -> dict:
    """Добавляет факт в story_entries + индексирует в Qdrant (синхронно)."""
    entry = StoryEntry(world_id=world_id, content=content, entry_type=entry_type,
                       metadata=metadata or {}, embedding_status="pending")
    db.add(entry)
    await db.flush()  # получаем entry.id

    try:
        vec = (await get_embedder().embed([content[:4000]]))[0]
        point_id = str(entry.id)
        await get_qdrant_client().upsert(
            collection_name="story_entries",
            points=[PointStruct(id=point_id, vector=vec,
                                payload={"world_id": str(world_id), "entry_id": point_id,
                                         "entry_type": entry_type,
                                         "step_id": str(metadata.get("step_id")) if metadata else None,
                                         "created_at": int(entry.created_at.timestamp())})]
        )
        entry.qdrant_point_id = point_id
        entry.embedding_status = "indexed"
    except Exception as e:
        logger.warning("rag_add_embed_failed", error=str(e), entry_id=str(entry.id))
        entry.embedding_status = "failed"
        # Фоновой индексатор попробует снова

    await db.commit()
    return {"id": str(entry.id), "status": entry.embedding_status}

11.1.6. Удаление мира и Qdrant-очистка

При DELETE /api/worlds/{id} (cascade delete в PostgreSQL) срабатывает хук app/api/worlds.py::_cleanup_qdrant(world_id):

async def _cleanup_qdrant(world_id: UUID) -> None:
    """Best-effort удаление точек мира из Qdrant. Ошибки логируем, но не падаем."""
    client = get_qdrant_client()
    for collection in ("entities", "story_entries"):
        try:
            await client.delete(
                collection_name=collection,
                points_selector=FilterSelector(
                    filter=Filter(must=[FieldCondition(key="world_id",
                                                       match=MatchValue(value=str(world_id)))]))
            )
        except Exception as e:
            logger.error("qdrant_cleanup_failed", collection=collection,
                         world_id=str(world_id), error=str(e))

Аналогично при soft-delete entity.deleted_at = now() — точка в Qdrant помечается deleted: true в payload (не удаляется физически, чтобы можно было восстановить; сборщик мусора раз в сутки удаляет deleted: true старше 7 дней).

11.2. Валидатор состояния

app/core/state_validator.py — критический модуль. Любое изменение environment или entity.data проходит через него.

Сигнатуры:

def validate_state(state: dict, schema: dict) -> tuple[bool, list[str]]:
    """
    Возвращает (ok, errors). errors — список человекочитаемых строк.
    Проверяет:
    - Все обязательные поля присутствуют.
    - Типы полей соответствуют объявленным.
    - Значения в диапазонах (для integer с max).
    - Структура object/array соответствует вложенной schema.
    """

def apply_patch(state: dict, patch: dict) -> tuple[dict, list[str]]:
    """
    Применяет JSON-patch к state и возвращает (new_state, errors).
    patch: {field_path: new_value} или {field_path: {op: "inc", by: N}}.
    Поддерживаемые op: 'set', 'inc', 'dec', 'append', 'remove'.
    Возвращает new_state только если errors пуст.
    """

def validate_world(world: World) -> tuple[bool, list[str]]:
    """
    Полная валидация мира: schemas, environment_schema, environment, current_time.
    Используется при создании/редактировании мира.
    """

Что валидатор проверяет обязательно:

Проверка Уровень Поведение при ошибке
Все обязательные поля environment_schema присутствуют в environment syntax errors.append("Missing required field: player")
player соответствует schema character (есть name, stats, stats.health — целое, etc.) syntax errors.append("player.stats.health must be integer")
current_location — непустая строка syntax errors.append("current_location must be non-empty")
current_location ссылается на существующую Entity типа location semantic (soft) warning, можно отключить в settings
plot_rails содержит hooks (list) и current_goals (list) syntax errors.append("plot_rails.hooks must be list")
current_time парсится как day_D_hour_H[_min_M] syntax errors.append("Invalid current_time format")
hour < hours_in_day из time_schema semantic errors.append("hour 25 exceeds hours_in_day 24")
Все типы полей соответствуют объявленным в schema (integer — int, boolean — bool, etc.) syntax errors.append("Field X must be integer, got string")
Для integer с max: значение ≤ max semantic errors.append("health 150 exceeds max 100")
Для array с max: длина ≤ max semantic errors.append("inventory has 20 items, max 10")

Поток валидации при env_update:

async def execute_env_update(patch: dict, ctx: ToolContext) -> ToolResult:
    new_env, errors = apply_patch(ctx.world.environment, patch)
    if errors:
        return ToolResult(ok=False, error={"code": "validation_error", "message": "; ".join(errors)})
    ok, errors = validate_state(new_env, ctx.world.environment_schema)
    if not ok:
        return ToolResult(ok=False, error={"code": "validation_error", "message": "; ".join(errors)})
    # Дополнительная семантическая валидация
    ok, errors = validate_world_state_semantic(new_env, ctx.world)
    if not ok:
        return ToolResult(ok=False, error={"code": "semantic_error", "message": "; ".join(errors)})

    ctx.world.environment = new_env
    await ctx.session.commit()
    return ToolResult(ok=True, data={"applied_paths": list(patch.keys())}, message="Environment updated")

11.3. JSON-patch формат

Поддерживаемые операции в patch:

Формат значения Операция Пример
{"field": value} set (заменить) {"player.stats.health": 50}
{"field": {"op": "inc", "by": N}} inc (прибавить) {"player.stats.health": {"op": "inc", "by": -10}}
{"field": {"op": "dec", "by": N}} dec (вычесть) {"player.stats.mana": {"op": "dec", "by": 5}}
{"field": {"op": "append", "value": X}} append (добавить в массив) {"player.inventory": {"op": "append", "value": {"item_id": "sword", "qty": 1}}}
{"field": {"op": "remove", "index": N}} remove (удалить из массива по индексу) {"player.inventory": {"op": "remove", "index": 2}}

field_path поддерживает точечную нотацию: player.stats.health, plot_rails.current_goals[0], schemas[1].properties[2].verbose.

11.4. Управление игровым временем

app/core/time_utils.py:

def parse_time(s: str) -> dict:
    """Парсит '[year_Y_]day_D_hour_H[_min_M]' в dict {year, day, hour, min}."""

def format_time(t: dict) -> str:
    """Сериализует dict в строку."""

def advance_time(current: str, delta: str, time_schema: dict) -> str:
    """
    Прибавляет delta к current, учитывая hours_in_day.
    Пример: advance_time('day_1_hour_23', 'hours_2', {hours_in_day: 24}) -> 'day_2_hour_1'.
    """

def compare_time(a: str, b: str) -> int:
    """-1 если a < b, 0 если a == b, 1 если a > b. Для deferred_triggers."""

11.5. Контекстная оптимизация (Context window management)

LLM-контекст ограничен (8K32K токенов для локальных моделей, до 128K для крупных cloud-моделей). Контекстный менеджер app/engine/context.py отвечает за то, чтобы каждый вызов LLM получал максимально информативный промпт, не превышающий бюджет. Это критически важно для долгих сессий: без сжатия контекст быстро переполняется, модель начинает «забывать» ранние события и галлюцинировать.

11.5.1. Бюджет токенов

Каждый промпт делится на сегменты с фиксированными и динамическими бюджетами:

Сегмент Типичный объём (токенов) Управление
System prompt (роль + правила + схемы + environment) 15003000 Статичный шаблон + interpolated из world.*
Summary (если есть) 300800 Генерируется в Phase 3, кешируется в story_entries
RAG-retrieved facts (если LLM вызывала rag_query) 01500 Динамически, по результатам tool call
Recent messages (guaranteed) 15004000 Последние N шагов (default 10)
Current action 50300 Текущее действие игрока
Reserved for completion 10244096 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. Оптимальный размер — 15002500 токенов; если превышает, контекстный менеджер логирует 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 == <current_world> обязателен для каждого rag_query. Это гарантирует, что:

  • Сущности и факты мира A никогда не попадут в контекст игры в мире B.
  • Удаление мира (DELETE /api/worlds/{id}) каскадно удаляет точки в Qdrant (см. §11.1.6).
  • Бэкап и восстановление мира можно делать независимо от других миров (см. §13.6).

11.5.5. Гибридный поиск (опционально, future)

В будущих спринтах (post-MVP) можно добавить гибридный поиск: BM25 (через PostgreSQL tsvector + GIN-индекс на entities.name, story_entries.content) + dense (Qdrant). RRF (Reciprocal Rank Fusion) объединяет ранжирования. Это улучшает поиск для коротких точных запросов (имена NPC, названия локаций). В текущем ТЗ не обязательно, но архитектура (двухстадийный retrieval из §11.1.4) это позволяет без переписывания.

11.5.6. Кеширование

  • Embedding cache: app/core/cache.py::EmbeddingCache — LRU на 1000 запросов, TTL 5 минут. Ключ = sha256(text)[:16]. Хранит только векторы запросов (rag_query); векторы документов живут в Qdrant.
  • Summary cache: один summary на мир на step_range; инвалидируется при откате шагов (см. DELETE /api/steps/{id} rollback).
  • Prompt cache: system-промпт кешируется после первой сборки для мира; инвалидируется при world.schemas / world.environment_schema изменениях (через dirty-flag в worlds.updated_at).

11.5.7. Оценка токенов

# app/core/tokens.py
def estimate_tokens(text: str, model: str) -> int:
    """Приближённая оценка token count.
    Для OpenAI-моделей — tiktoken; для остальных — len(text)/4."""
    try:
        import tiktoken
        enc = tiktoken.encoding_for_model(model)
        return len(enc.encode(text))
    except Exception:
        return max(1, len(text) // 4)

Используется в контекстном менеджере для всех сегментов. System prompt оценивается один раз при первой сборке и кешируется (его размер не меняется в пределах итерации, если не было env_update).

11.5.8. Настройки контекста (в settings.context.*)

Ключ Default Назначение
context.guaranteed_messages 10 Сколько последних шагей всегда в контексте
context.compression_threshold_messages 20 Порог сжатия по числу шагов
context.compression_threshold_tokens 6000 Альтернативный порог по токенам
context.scene_text_truncate_tokens 500 Урезка scene_text в recent messages
context.auto_rag_on_entity_mention false Авто-вызов rag_query при упоминании сущности
context.safety_margin_tokens 500 Резерв от края context window

11.6. Embeddings subsystem

11.6.1. Конфигурация (settings.embeddings.*)

Ключ Тип Default Назначение
embeddings.provider string "offline_hash" offline_hash | openai
embeddings.api_url string "" URL OpenAI-compatible endpoint. Если пусто и provider=openai — fallback на llm.api_url.
embeddings.api_key string "" API-ключ. Если пусто и provider=openai — fallback на llm.api_key.
embeddings.model string "text-embedding-3-small" Имя модели эмбеддингов
embeddings.dimension integer 1536 Размерность. Кнопка «Авто-проба» в UI вызывает POST /api/admin/test/embeddings/probe-dimension и подставляет результат.
embeddings.timeout_seconds integer 30 Timeout на вызов embeddings API
embeddings.batch_size integer 32 Размер батча для embedding API
embeddings.cache_ttl_seconds integer 300 TTL LRU-кеша для query embeddings
embeddings.max_text_chars integer 4000 Урезка текста перед эмбеддингом (для story_entries.content)

11.6.2. Fallback-правило для OpenAI-провайдера

Если embeddings.provider == "openai" и не заданы embeddings.api_url или embeddings.api_key, конструктор OpenAIEmbedder берёт значения из llm.api_url и llm.api_key. Это позволяет единой конфигурацией LLM (llm.api_url + llm.api_key) закрыть и chat-completions, и embeddings — удобно для self-hosted инстансов (vLLM, ollama, lmdeploy) и для OpenAI-аккаунтов с обоими API.

# app/core/embeddings.py
def build_embedder() -> Embedder:
    provider = settings.get("embeddings.provider")
    dim = settings.get("embeddings.dimension")
    if provider == "offline_hash":
        return HashEmbedder(dimension=dim or 256)
    if provider == "openai":
        # Fallback: если embeddings.api_url/api_key пустые — берём llm.*
        api_url = settings.get("embeddings.api_url") or settings.get("llm.api_url")
        api_key = settings.get("embeddings.api_key") or settings.get("llm.api_key")
        if not api_url or not api_key:
            raise ValueError(
                "OpenAI embedder requires api_url and api_key "
                "(either embeddings.* or llm.* fallback)"
            )
        model = settings.get("embeddings.model") or "text-embedding-3-small"
        timeout = settings.get("embeddings.timeout_seconds") or 30
        return OpenAIEmbedder(api_url=api_url, api_key=api_key, model=model,
                              dimension=dim, timeout=timeout)
    raise ValueError(f"Unknown embeddings provider: {provider}")

11.6.3. Авто-проба размерности (embeddings.dimension)

Размерность вектора нельзя угадать по имени модели — text-embedding-3-small поддерживает 512/1536/3072, локальные модели на vLLM могут иметь 768/1024/4096. Поэтому в админке есть кнопка «Авто-проба» рядом с полем embeddings.dimension:

  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.

# app/workers/embedding_indexer.py
import asyncio, json
from app.core.embeddings import get_embedder
from app.core.rag import get_qdrant_client
from qdrant_client.http.models import PointStruct

async def embedding_indexer_loop():
    while True:
        try:
            embedder = get_embedder()
            qdrant = get_qdrant_client()
            dim = embedder.dimension

            # Entities
            pending = await db.execute(
                select(Entity)
                .where(Entity.embedding_status == "pending", Entity.deleted_at.is_(None))
                .limit(50)
            )
            for entity in pending.scalars():
                text = f"{entity.name}\n{json.dumps(entity.data, ensure_ascii=False)[:2000]}"
                try:
                    vec = (await embedder.embed([text]))[0]
                    point_id = str(entity.id)
                    await qdrant.upsert(
                        collection_name="entities",
                        points=[PointStruct(id=point_id, vector=vec,
                            payload={"world_id": str(entity.world_id),
                                     "entity_id": point_id,
                                     "entity_type": entity.entity_type,
                                     "name": entity.name, "deleted": False})]
                    )
                    entity.qdrant_point_id = point_id
                    entity.embedding_status = "indexed"
                except Exception as e:
                    logger.warning("embedding_index_entity_failed",
                                   entity_id=str(entity.id), error=str(e))
                    entity.embedding_status = "failed"

            # StoryEntries — аналогично
            # ... (см. полный код в app/workers/embedding_indexer.py)

            await db.commit()
        except Exception as e:
            logger.exception("embedding_indexer_error", error=str(e))
        await asyncio.sleep(30)

Записи с embedding_status='failed' (после 3 ретраев) попадают в лог и доступны для ручного retry через POST /api/admin/embeddings/retry-failed.

11.6.5. Текст для эмбеддинга

Чтобы вектор был информативным, текст формируется не только из основного поля:

Сущность Формула Пример
Entity (character) name + "\n" + role + "\n" + key_stats + "\n" + description «Sir Galahad\nknight\nHP:80/100, STR:16\nA noble warrior sworn to...»
Entity (item) name + "\n" + item_type + "\n" + properties «Iron Sword\nweapon\ndamage:1d8+1, weight:3kg»
Entity (location) name + "\n" + description «Whispering Forest\nA dense wood where...»
StoryEntry content (с урезкой до embeddings.max_text_chars) «Игрок убил дракона в день 3 час 14.»

Формула фиксирована в app/core/embeddings.py::build_text_for_entity(entity) и ::build_text_for_story_entry(entry). Менять формулу = пересоздать коллекцию и переиндексировать.

11.6.6. Тестовые кнопки (см. §6.5 и §12.4)

В админке на странице настроек есть три тестовые кнопки + кнопка авто-пробы:

Кнопка Endpoint Что делает Что показывает UI
«Тест LLM» POST /api/admin/test/llm Отправляет "Reply with: OK" в chat/completions Ответ модели + latency + token counts
«Тест LLM вызов инструментов» POST /api/admin/test/llm-tools Отправляет промпт "What is 2+2? Use the calc tool." + 1 инструмент calc Был ли tool_calls в ответе, latency, имя вызванного инструмента
«Тест эмбеддинг» POST /api/admin/test/embeddings Отправляет "hello world" в embeddings API dimension + первые 5 значений вектора + latency
«Авто-проба размерности» POST /api/admin/test/embeddings/probe-dimension То же что «Тест эмбеддинг», но возвращает только dimension dimension + кнопка «Сохранить в embeddings.dimension»

Все четыре кнопки доступны без сохранения настроек — берут текущие значения из формы (query params ?api_url=&api_key=&model=), что позволяет тестировать перед сохранением. См. §6.5 для контрактов endpoints.


12. Фронтенд-архитектура

12.1. Структура директорий

frontend/
├── src/
│   ├── main.tsx
│   ├── App.tsx
│   ├── routes/
│   │   ├── index.tsx              # Маршруты (react-router v6)
│   │   ├── ProtectedRoute.tsx
│   │   └── AdminRoute.tsx
│   ├── pages/
│   │   ├── LoginPage.tsx
│   │   ├── RegisterPage.tsx
│   │   ├── RegisterAdminPage.tsx
│   │   ├── WorldsListPage.tsx
│   │   ├── WorldCreatePage.tsx
│   │   ├── WorldEditPage.tsx
│   │   ├── WorldPlayPage.tsx
│   │   └── admin/
│   │       ├── AdminSettingsPage.tsx
│   │       ├── AdminLogsPage.tsx
│   │       └── AdminUsersPage.tsx
│   ├── components/
│   │   ├── ui/                    # Базовые компоненты
│   │   │   ├── Button.tsx
│   │   │   ├── Card.tsx
│   │   │   ├── Input.tsx
│   │   │   ├── Modal.tsx
│   │   │   ├── Navbar.tsx
│   │   │   ├── Spinner.tsx
│   │   │   └── cn.ts              # classnames утилита
│   │   ├── chat/
│   │   │   ├── ChatWindow.tsx
│   │   │   ├── ChatMessage.tsx
│   │   │   ├── ToolCallBubble.tsx
│   │   │   ├── ActionSelector.tsx
│   │   │   └── ClarificationModal.tsx
│   │   ├── world/
│   │   │   ├── WorldCard.tsx
│   │   │   ├── EnvironmentPanel.tsx
│   │   │   ├── EntityList.tsx
│   │   │   ├── PlotRailsPanel.tsx
│   │   │   └── JsonEditor.tsx
│   │   └── admin/
│   │       ├── SettingsForm.tsx
│   │       ├── LogsTable.tsx
│   │       ├── LogDetailModal.tsx
│   │       ├── ConnectionTests.tsx       # блок с 3 тестовыми кнопками + probe-dimension
│   │       ├── TestLlmButton.tsx         # «Тест LLM»
│   │       ├── TestLlmToolsButton.tsx    # «Тест LLM вызов инструментов»
│   │       ├── TestEmbeddingsButton.tsx  # «Тест эмбеддинг»
│   │       ├── ProbeDimensionButton.tsx  # «Авто-проба размерности»
│   │       ├── IconUploader.tsx          # загрузка favicon/logo через UI
│   │       └── TestResultCard.tsx        # карточка с результатом теста
│   ├── stores/                    # zustand
│   │   ├── authStore.ts
│   │   ├── uiStore.ts
│   │   ├── worldsStore.ts
│   │   └── sessionStore.ts
│   ├── api/                       # HTTP-клиент
│   │   ├── client.ts              # fetch wrapper with JWT
│   │   ├── auth.ts
│   │   ├── worlds.ts
│   │   ├── sessions.ts
│   │   ├── admin.ts
│   │   └── sse.ts                 # EventSource wrapper
│   ├── i18n/
│   │   ├── config.ts
│   │   ├── en.json
│   │   └── ru.json
│   ├── lib/
│   │   ├── utils.ts
│   │   └── constants.ts
│   └── types/
│       ├── api.ts                 # типы из OpenAPI
│       ├── world.ts
│       └── sse.ts
├── public/
├── index.html
├── vite.config.ts
├── tailwind.config.js
├── tsconfig.json
└── package.json

12.2. Zustand stores

authStore

interface AuthState {
  user: User | null;
  accessToken: string | null;
  refreshToken: string | null;
  isAuthenticated: boolean;
  login: (creds: LoginRequest) => Promise<void>;
  logout: () => void;
  refresh: () => Promise<void>;
  fetchMe: () => Promise<void>;
}

Хранит токены в localStorage. При истечении access_token — автоматически вызывает refresh().

worldsStore

interface WorldsState {
  worlds: WorldSummary[];
  currentWorld: World | null;
  isLoading: boolean;
  error: string | null;
  fetchWorlds: () => Promise<void>;
  fetchWorld: (id: string) => Promise<void>;
  createWorld: (req: CreateWorldRequest) => Promise<string>;
  deleteWorld: (id: string) => Promise<void>;
}

sessionStore

interface SessionState {
  steps: Step[];
  environment: Environment | null;
  nextActions: string[];
  isIterating: boolean;
  currentPhase: 1 | 2 | 3 | null;
  currentToolCall: ToolCall | null;
  sceneStreaming: boolean;
  partialScene: string;
  sseConnection: EventSource | null;

  connectStream: (url: string) => void;
  disconnectStream: () => void;
  iterate: (action: string, source: 'custom' | 'suggested') => Promise<void>;
  retry: () => Promise<void>;
  rollback: () => Promise<void>;
  answerClarification: (text: string) => Promise<void>;
}

uiStore

interface TestResult {
  kind: 'llm' | 'llm_tools' | 'embeddings' | 'probe_dimension';
  ok: boolean;
  warning?: boolean;
  data: Record<string, unknown>;  // response, dimension, latency_ms, ...
  error?: { code: string; message: string };
  testedAt: string;  // ISO timestamp
}

interface UIState {
  language: 'en' | 'ru';
  theme: 'light' | 'dark';
  sidebarOpen: boolean;
  // Результаты тестовых кнопок (§12.4 п.10-12)
  testResults: TestResult[];        // последние 10 результатов
  addTestResult: (r: TestResult) => void;
  clearTestResults: () => void;
  // Иконки, загруженные через UI (§12.4 п.11)
  faviconUrl: string | null;
  logoUrl: string | null;
  ogImageUrl: string | null;
  setIcon: (kind: 'favicon' | 'logo' | 'og_image', url: string) => void;
  // Базовые setter-ы
  setLanguage: (lang: 'en' | 'ru') => void;
  toggleTheme: () => void;
}

12.3. SSE-обработчик

src/api/sse.ts:

export class SSEClient {
  private eventSource: EventSource | null = null;
  private lastEventId: string | null = null;

  connect(url: string, handlers: SSEHandlers): void {
    this.eventSource = new EventSource(url, { withCredentials: true });
    this.eventSource.onmessage = (e) => this.handle('message', e);
    this.eventSource.addEventListener('phase_start', (e) => handlers.onPhaseStart?.(JSON.parse(e.data)));
    this.eventSource.addEventListener('tool_call', (e) => handlers.onToolCall?.(JSON.parse(e.data)));
    this.eventSource.addEventListener('scene_chunk', (e) => handlers.onSceneChunk?.(JSON.parse(e.data)));
    this.eventSource.addEventListener('scene_complete', (e) => handlers.onSceneComplete?.(JSON.parse(e.data)));
    this.eventSource.addEventListener('suggested_actions', (e) => handlers.onSuggestedActions?.(JSON.parse(e.data)));
    this.eventSource.addEventListener('error', (e) => handlers.onError?.(JSON.parse(e.data)));
    this.eventSource.addEventListener('done', (e) => {
      handlers.onDone?.(JSON.parse(e.data));
      this.disconnect();
    });
    this.eventSource.addEventListener('ping', (e) => {
      this.lastEventId = e.lastEventId;
    });
  }

  disconnect(): void {
    this.eventSource?.close();
    this.eventSource = null;
  }
}

12.4. UX-требования

  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 — плоские ключи:

{
  "common.save": "Сохранить",
  "common.cancel": "Отмена",
  "common.retry": "Повторить",
  "auth.login.title": "Вход в систему",
  "auth.login.email_or_username": "Email или имя пользователя",
  "worlds.list.title": "Мои миры",
  "worlds.list.empty": "У вас пока нет миров. Создайте первый!",
  "play.phase.1": "Планирование",
  "play.phase.2": "Написание сцены",
  "play.phase.3": "Сохранение",
  "play.tool_call.env_update": "Изменение окружения",
  "play.tool_call.entity_create": "Создание сущности",
  "play.error.timeout": "Время ожидания истекло. Попробуйте ещё раз."
}

13. DevOps / Deployment

13.1. Docker Compose

docker-compose.yml:

version: '3.9'

services:
  db:
    image: postgres:15-alpine
    container_name: airpg_db
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - db_data:/var/lib/postgresql/data
      - ${DATA_DIR}/backups:/backups
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5

  qdrant:
    image: qdrant/qdrant:v1.8.4
    container_name: airpg_qdrant
    environment:
      QDRANT__SERVICE__GRPC_PORT: "6334"
      QDRANT__SERVICE__HTTP_PORT: "6333"
      QDRANT__LOG_LEVEL: ${QDRANT_LOG_LEVEL:-INFO}
      # Включить авторизацию в проде: см. Qdrant docs, API key scopes
      QDRANT__SERVICE__API_KEY: ${QDRANT_API_KEY:-}
    volumes:
      - qdrant_data:/qdrant/storage
      - ${DATA_DIR}/qdrant_snapshots:/qdrant/snapshots
    ports:
      - "6333:6333"   # HTTP (REST + Web UI)
      - "6334:6334"   # gRPC (используется qdrant-client)
    healthcheck:
      test: ["CMD-SHELL", "bash -c ':> /dev/tcp/127.0.0.1/6333' || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 5

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: airpg_backend
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
      qdrant:
        condition: service_healthy
    ports:
      - "8000:8000"
    volumes:
      - ${DATA_DIR}:/app/data
      - ./backend/app:/app/app  # для dev hot-reload
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    container_name: airpg_frontend
    depends_on:
      - backend
    ports:
      - "80:80"
    volumes:
      - ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro

volumes:
  db_data:
  qdrant_data:

13.2. Backend Dockerfile

FROM python:3.12-slim

WORKDIR /app

RUN apt-get update && apt-get install -y \
    build-essential \
    libpq-dev \
    curl \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

13.3. Frontend Dockerfile + nginx

# build stage
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# serve stage
FROM nginx:1.24-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

nginx.conf:

server {
    listen 80;
    server_name _;

    root /usr/share/nginx/html;
    index index.html;

    location /api/ {
        proxy_pass http://backend:8000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # SSE: отключаем буферизацию
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 300s;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }
}

13.4. .env — полный список переменных

# === Database ===
POSTGRES_DB=airpg
POSTGRES_USER=airpg
POSTGRES_PASSWORD=changeme_strong_password
POSTGRES_HOST=db
POSTGRES_PORT=5432
DATABASE_URL=postgresql+asyncpg://airpg:changeme_strong_password@db:5432/airpg

# === JWT ===
JWT_SECRET=change_this_to_random_64_char_string
JWT_ALGORITHM=HS256
JWT_ACCESS_EXPIRE_MINUTES=1440
JWT_REFRESH_EXPIRE_DAYS=7

# === Admin Setup ===
ADMIN_SETUP_TOKEN=  # empty = generate random on startup

# === LLM ===
LLM_API_URL=http://localhost:11434/v1
LLM_API_KEY=
LLM_MODEL=qwen2.5-7b-instruct

# === Embeddings ===
# Если EMBEDDINGS_PROVIDER=openai и EMBEDDINGS_API_URL/EMBEDDINGS_API_KEY пусты —
# берутся LLM_API_URL/LLM_API_KEY (fallback, см. §11.6.2).
EMBEDDINGS_PROVIDER=offline_hash  # or "openai"
EMBEDDINGS_API_URL=               # пусто = fallback на LLM_API_URL
EMBEDDINGS_API_KEY=               # пусто = fallback на LLM_API_KEY
EMBEDDINGS_MODEL=text-embedding-3-small
EMBEDDINGS_DIMENSION=1536         # кнопка «Авто-проба» в UI подставит правильное
EMBEDDINGS_TIMEOUT_SECONDS=30
EMBEDDINGS_BATCH_SIZE=32
EMBEDDINGS_CACHE_TTL_SECONDS=300
EMBEDDINGS_MAX_TEXT_CHARS=4000

# === Qdrant ===
QDRANT_URL=http://qdrant:6333
QDRANT_API_KEY=                   # пусто = без авторизации (dev); в prod обязателен
QDRANT_COLLECTION_PREFIX=        # для multi-tenant деплоя
QDRANT_LOG_LEVEL=INFO

# === Context Manager ===
CONTEXT_GUARANTEED_MESSAGES=10
CONTEXT_COMPRESSION_THRESHOLD_MESSAGES=20
CONTEXT_COMPRESSION_THRESHOLD_TOKENS=6000
CONTEXT_SCENE_TEXT_TRUNCATE_TOKENS=500
CONTEXT_AUTO_RAG_ON_ENTITY_MENTION=false
CONTEXT_SAFETY_MARGIN_TOKENS=500

# === Data ===
DATA_DIR=/app/data

# === App ===
APP_ENV=development  # development | staging | production
APP_LOG_LEVEL=INFO  # DEBUG | INFO | WARNING | ERROR
APP_CORS_ORIGINS=http://localhost:5173,http://localhost

# === Rate Limiting ===
RATE_LIMIT_PER_MINUTE=60
RATE_LIMIT_LLM_PER_MINUTE=10

13.5. CI/CD (GitHub Actions)

.github/workflows/ci.yml:

name: CI

on: [push, pull_request]

jobs:
  backend-test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15-alpine
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: airpg_test
          POSTGRES_USER: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432
      qdrant:
        image: qdrant/qdrant:v1.8.4
        ports:
          - 6333:6333
          - 6334:6334
        options: >-
          --health-cmd ":> /dev/tcp/127.0.0.1/6333"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    env:
      DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/airpg_test
      QDRANT_URL: http://localhost:6333
      EMBEDDINGS_PROVIDER: offline_hash
      LLM_API_URL: http://localhost:11434/v1
      LLM_MODEL: mock
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
      - run: cd backend && pytest --cov=app --cov-report=xml --cov-fail-under=80
      - uses: codecov/codecov-action@v4

  frontend-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: cd frontend && npm ci
      - run: cd frontend && npm run lint
      - run: cd frontend && npm run typecheck
      - run: cd frontend && npm run test -- --coverage
      - run: cd frontend && npx playwright test

  docker-build:
    needs: [backend-test, frontend-test]
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - run: docker compose build
      - run: docker compose push  # если настроен registry

13.6. Бэкапы БД + Qdrant

Cron-скрипт scripts/backup_all.sh делает резервную копию PostgreSQL и Qdrant. Qdrant-снапшот создаётся через REST API POST /collections/{collection_name}/snapshots, файлы сохраняются в ${DATA_DIR}/qdrant_snapshots/ (проброшен volume в docker-compose).

#!/bin/bash
set -euo pipefail

BACKUP_DIR="${DATA_DIR}/backups"
QDRANT_SNAP_DIR="${DATA_DIR}/qdrant_snapshots"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)

mkdir -p "${BACKUP_DIR}" "${QDRANT_SNAP_DIR}"

# 1. PostgreSQL dump
PG_FILE="${BACKUP_DIR}/airpg_${TIMESTAMP}.sql.gz"
docker exec airpg_db pg_dump -U "${POSTGRES_USER}" "${POSTGRES_DB}" | gzip > "${PG_FILE}"

# 2. Qdrant snapshots для каждой коллекции
for COLLECTION in entities story_entries; do
  # Запрашиваем создание снапшота на стороне Qdrant
  SNAP_INFO=$(curl -s -X POST "${QDRANT_URL}/collections/${COLLECTION}/snapshots")
  SNAP_NAME=$(echo "${SNAP_INFO}" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['name'])")
  # Скачиваем снапшот в локальную папку
  curl -s -o "${QDRANT_SNAP_DIR}/${COLLECTION}_${TIMESTAMP}.tar" \
    "${QDRANT_URL}/collections/${COLLECTION}/snapshots/${SNAP_NAME}"
  # Удаляем снапшот с Qdrant-инстанса (файл уже у нас)
  curl -s -X DELETE "${QDRANT_URL}/collections/${COLLECTION}/snapshots/${SNAP_NAME}" > /dev/null
done

# Храним последние 30 дней
find "${BACKUP_DIR}" -name "airpg_*.sql.gz" -mtime +30 -delete
find "${QDRANT_SNAP_DIR}" -name "*.tar" -mtime +30 -delete

echo "Backup saved: ${PG_FILE}, Qdrant snapshots: ${QDRANT_SNAP_DIR}/${COLLECTION}_${TIMESTAMP}.tar (x2)"

Восстановление (disaster recovery):

# 1. PostgreSQL
zcat ${DATA_DIR}/backups/airpg_YYYYMMDD.sql.gz | docker exec -i airpg_db psql -U ${POSTGRES_USER} ${POSTGRES_DB}

# 2. Qdrant (для каждой коллекции)
curl -X PUT "${QDRANT_URL}/collections/entities/snapshots/upload" \
  -F 'file=@${DATA_DIR}/qdrant_snapshots/entities_YYYYMMDD.tar'

Cron: 0 3 * * * /app/scripts/backup_all.sh (ежедневно в 3:00).

13.7. Логирование

Структурированные логи в JSON через structlog:

import structlog
logger = structlog.get_logger()

logger.info("llm_call",
    stage="orchestrator_phase1",
    world_id=str(world_id),
    step_id=str(step_id),
    model="qwen2.5-7b",
    latency_ms=1234,
    tokens=567,
)

В продакшене логи идут в stdout, docker собирает их через logging driver в centralized system (Loki/ELK).

13.8. Health-checks

  • GET /api/health — liveness + readiness: {status, db, qdrant, llm, embeddings, version}. Проверки:
    • dbSELECT 1 через SQLAlchemy.
    • qdrantGET /collections через qdrant-client (проверяем что коллекции entities и story_entries существуют).
    • llmfalse если settings.llm.api_url пустой; true если последний вызов LLM был успешным (по llm_call_logs за последние 5 минут).
    • embeddingsfalse если 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 — ключевые фикстуры

import pytest
import pytest_asyncio
from httpx import AsyncClient
from testcontainers.postgres import PostgresContainer
from testcontainers.qdrant import QdrantContainer  # см. https://github.com/testcontainers/testcontainers-python

@pytest_asyncio.fixture
async def db_session():
    """Изолированная БД для каждого теста через testcontainers."""
    with PostgresContainer("postgres:15-alpine") as pg:
        engine = create_async_engine(pg.get_connection_url())
        async with engine.begin() as conn:
            await conn.run_sync(Base.metadata.create_all)
        async with AsyncSession(engine) as session:
            yield session

@pytest_asyncio.fixture
async def qdrant_client():
    """Изолированный Qdrant для каждого теста через testcontainers."""
    with QdrantContainer("qdrant/qdrant:v1.8.4") as qd:
        client = AsyncQdrantClient(url=f"http://localhost:{qd.get_exposed_port(6333)}")
        # Создаём тестовые коллекции с малой размерностью (для HashEmbedder)
        await init_qdrant_collections(dimension=256)
        yield client

@pytest_asyncio.fixture
async def mock_llm_client():
    """LLM-клиент, который возвращает предзаписанные ответы."""
    return MockLLMClient(replay_dir="tests/fixtures/llm_replay/")

@pytest_asyncio.fixture
async def api_client(db_session, mock_llm_client):
    """FastAPI TestClient с подменённой БД и LLM."""
    app = create_app(db_session, mock_llm_client)
    async with AsyncClient(app=app, base_url="http://test") as client:
        yield client

@pytest.fixture
def sample_world():
    """Готовый мир для тестов."""
    return load_fixture("db_seeds/fantasy_world.json")

@pytest.fixture
def admin_user(db_session):
    user = User(email="admin@test", username="admin", is_admin=True, ...)
    db_session.add(user)
    await db_session.commit()
    return user

@pytest.fixture
def auth_token(admin_user):
    return create_access_token({"sub": str(admin_user.id)})

MockLLMClient — replay-based мок

class MockLLMClient:
    """
    Возвращает предзаписанные ответы вместо реальных LLM-вызовов.
    Replay-файл: {stage: [sequence_of_responses]}.
    Каждый вызов LLM возвращает следующий ответ из sequence.
    """

    def __init__(self, replay_dir: str):
        self.replay_dir = replay_dir
        self._call_counts: dict[str, int] = {}

    async def complete(self, stage: str, messages: list, tools: list = None, **kwargs) -> dict:
        replay_file = Path(self.replay_dir) / f"{stage}.json"
        replay_data = json.loads(replay_file.read_text())
        idx = self._call_counts.get(stage, 0)
        if idx >= len(replay_data):
            raise RuntimeError(f"Replay exhausted for stage {stage}")
        response = replay_data[idx]
        self._call_counts[stage] = idx + 1
        return response

    async def stream(self, stage: str, messages: list, **kwargs):
        replay_file = Path(self.replay_dir) / f"{stage}.json"
        replay_data = json.loads(replay_file.read_text())
        for chunk in replay_data:
            yield chunk

Запись replay-фикстур: в dev-режиме флаг LLM_RECORD_REPLAY=true заставляет реальный LlmClient писать каждый ответ в JSON-файл. Эти файлы коммитятся в репозиторий и используются в тестах.

14.4. Примеры TDD-циклов

Пример 1: state_validator.apply_patch

Red — пишем failing тест:

# tests/unit/core/test_state_validator.py
import pytest
from app.core.state_validator import apply_patch

class TestApplyPatch:
    def test_set_operation_replaces_value(self):
        state = {"player": {"name": "Эрик", "stats": {"health": 100}}}
        patch = {"player.stats.health": 50}
        new_state, errors = apply_patch(state, patch)
        assert errors == []
        assert new_state["player"]["stats"]["health"] == 50

    def test_inc_operation_adds_to_value(self):
        state = {"player": {"stats": {"health": 100}}}
        patch = {"player.stats.health": {"op": "inc", "by": -10}}
        new_state, errors = apply_patch(state, patch)
        assert errors == []
        assert new_state["player"]["stats"]["health"] == 90

    def test_inc_on_non_integer_returns_error(self):
        state = {"player": {"name": "Эрик"}}
        patch = {"player.name": {"op": "inc", "by": 1}}
        new_state, errors = apply_patch(state, patch)
        assert errors == ["Cannot inc field 'player.name': not an integer"]
        assert new_state is None

    def test_unknown_operation_returns_error(self):
        state = {"player": {"health": 100}}
        patch = {"player.health": {"op": "multiply", "by": 2}}
        _, errors = apply_patch(state, patch)
        assert "Unknown operation: multiply" in errors[0]

    def test_nested_path_with_array_index(self):
        state = {"inventory": [{"item_id": "sword", "qty": 1}]}
        patch = {"inventory[0].qty": 5}
        new_state, errors = apply_patch(state, patch)
        assert errors == []
        assert new_state["inventory"][0]["qty"] == 5

Green — минимальная реализация:

# app/core/state_validator.py
def apply_patch(state: dict, patch: dict) -> tuple[dict | None, list[str]]:
    new_state = deepcopy(state)
    errors = []
    for path, op_spec in patch.items():
        try:
            if isinstance(op_spec, dict) and "op" in op_spec:
                op = op_spec["op"]
                if op == "inc":
                    _apply_inc(new_state, path, op_spec["by"])
                elif op == "dec":
                    _apply_inc(new_state, path, -op_spec["by"])
                elif op == "set":
                    _apply_set(new_state, path, op_spec.get("value"))
                elif op == "append":
                    _apply_append(new_state, path, op_spec["value"])
                elif op == "remove":
                    _apply_remove(new_state, path, op_spec["index"])
                else:
                    errors.append(f"Unknown operation: {op}")
            else:
                _apply_set(new_state, path, op_spec)
        except ValidationError as e:
            errors.append(str(e))
    if errors:
        return None, errors
    return new_state, []

Refactor: вынести общую логику навигации по path в _resolve_path(), переиспользовать в apply_patch и validate_state.

Пример 2: orchestrator Phase 1 integration test

# tests/integration/flows/test_orchestrator_flow.py
import pytest

@pytest.mark.asyncio
async def test_orchestrator_phase1_calls_tools_and_submits_plan(
    api_client, sample_world, mock_llm_client, auth_token
):
    """
    Phase 1 должна:
    1. Вызвать LLM минимум 1 раз.
    2. LLM вызывает env_update (из mocking replay).
    3. Завершается submit_plan.
    """
    world_id = await create_world(api_client, auth_token, sample_world)

    response = await api_client.post(
        f"/api/sessions/worlds/{world_id}/iterate",
        json={"action": "Я открываю дверь", "action_source": "custom"},
        headers={"Authorization": f"Bearer {auth_token}"},
    )
    assert response.status_code == 202
    stream_url = response.json()["stream_url"]

    events = await collect_sse_events(api_client, stream_url, auth_token)

    # Проверки
    assert any(e["event"] == "phase_start" and e["data"]["phase"] == 1 for e in events)
    assert any(e["event"] == "tool_call" and e["data"]["tool"] == "env_update" for e in events)
    assert any(e["event"] == "phase_end" and e["data"]["phase"] == 1 for e in events)
    assert events[-1]["event"] == "done"

14.5. Метрики покрытия

  • Целевое покрытие: ≥ 80% для app/core/, app/engine/, app/api/. ≥ 60% для app/prompts/ (сложно тестировать промпты).
  • Команда: pytest --cov=app --cov-report=html --cov-fail-under=80.
  • CI блокирует merge, если coverage упал ниже порога.
  • Покрытие по типам:
    • Unit: 90%+ (быстрые, изолированные).
    • Integration: 70%+ (через API + testcontainers).
    • E2E: ключевые сценарии (создание мира, итерация, редактирование).

14.6. Фронтенд-тесты

  • vitest для unit-тестов stores и утилит.
  • @testing-library/react для component-тестов.
  • Playwright для E2E (полный сценарий: логин → создание мира → итерация).
// frontend/src/stores/__tests__/sessionStore.test.ts
import { renderHook, act } from '@testing-library/react';
import { useSessionStore } from '../sessionStore';

test('iterate updates isIterating flag during call', async () => {
  const { result } = renderHook(() => useSessionStore());
  expect(result.current.isIterating).toBe(false);

  await act(async () => {
    await result.current.iterate('Открыть дверь', 'custom');
  });

  expect(result.current.isIterating).toBe(false); // после завершения
  expect(result.current.steps).toHaveLength(1);
});

14.7. Когда тесты не нужны

  • Pure data classes (Pydantic models без логики).
  • Миграции Alembic (тестируются через alembic upgrade head в CI).
  • UI-компоненты без логики (Button, Card).

15. Нефункциональные требования

15.1. Производительность

Метрика Цель Как измерять
Latency GET /api/worlds (p95) < 200ms Prometheus histogram
Latency POST /api/sessions/.../iterate (от запроса до iteration_complete) < 30s (одна фаза LLM ~5s) SSE timestamp diff
Latency LLM single call (p50 / p95) 2s / 8s llm_call_logs.latency_ms
Throughput orchestrator iterations per minute ≥ 10 (один сервер) counter запросов
DB pool size 20 connections (default), настраивается SQLAlchemy metrics
SSE max concurrent connections per server 200 uvicorn worker config
Frontend first contentful paint < 1.5s Lighthouse
Frontend time to interactive < 3s Lighthouse

Оптимизации:

  • LLM streaming для Phase 2 — пользователь видит текст сразу, не ждёт завершения.
  • Connection pool к БД — переиспользование соединений.
  • Индексы на worlds(owner_id, last_played_at), entities(world_id, entity_type) — критичны для списков.
  • Pagination на всех list-эндпоинтах — не возвращать > 50 элементов за раз.
  • Lazy loading фронтенда — code splitting по маршрутам.

15.2. Безопасность

Требование Реализация
Аутентификация JWT (HS256), access token 24h, refresh token 7d. Blacklist refresh tokens при logout.
Хеширование паролей bcrypt с cost factor 12.
Authorisation RBAC: user / admin. Проверка owner_id на всех мутациях World/Entity.
Валидация ввода Pydantic на всех API endpoints. JSON-schema на всех tool calls.
SQL Injection Только SQLAlchemy parameterized queries. Никаких f-string SQL.
XSS React по умолчанию экранирует. dangerouslySetInnerHTML запрещён без review.
CSRF JWT в Authorization header (не cookie) — CSRF не применим.
CORS APP_CORS_ORIGINS whitelist в .env.
Rate limiting RATE_LIMIT_PER_MINUTE=60 для обычных endpoints, RATE_LIMIT_LLM_PER_MINUTE=10 для LLM-вызовов. Через slowapi.
Secrets .env не коммитится. .env.example с placeholder значениями. API-ключи в settings шифруются на уровне приложения (Fernet).
Audit log llm_call_logs — полный лог всех LLM-вызовов. step_tool_calls — аудит tool calls.
Password policy ≥ 8 символов, минимум 1 буква + 1 цифра, blacklist top-1000 утечек.
HTTPS nginx с Let's Encrypt в продакшене. HTTP→HTTPS redirect.
Admin setup token rotation каждые 24 часа (cron task).

15.3. Наблюдаемость (Observability)

Логи:

  • Структурированный JSON через structlog.
  • Уровни: DEBUG (dev), INFO (prod), WARNING, ERROR.
  • Все LLM-вызовы логируются с stage, world_id, step_id, latency_ms, tokens, status.
  • Все ошибки логируются со stacktrace.

Метрики (Prometheus):

  • http_requests_total{method, path, status} — counter.
  • http_request_duration_seconds{method, path} — histogram.
  • llm_calls_total{stage, status} — counter.
  • llm_call_duration_seconds{stage} — histogram.
  • sse_active_connections — gauge.
  • db_pool_size{used, total} — gauge.

Tracing (опционально, OpenTelemetry):

  • span на каждый HTTP-запрос.
  • span на каждый LLM-вызов с 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)

16.4. Логирование ошибок

try:
    result = await llm.complete(...)
except LLMTimeoutError as e:
    logger.error("llm_timeout",
        stage=stage,
        world_id=str(world_id),
        step_id=str(step_id),
        timeout_seconds=settings["llm.timeout_seconds"],
        error=str(e),
    )
    await sse_emitter.emit("error", {"code": "llm_timeout", "message": "LLM не ответила вовремя"})
    raise
except Exception as e:
    logger.exception("unexpected_error", stage=stage, error=str(e))
    await sse_emitter.emit("error", {"code": "internal_error", "message": "Непредвиденная ошибка"})
    raise

16.5. Fallback-стратегии

Сценарий Fallback
LLM не вызвала submit_plan за лимит ORC форсит завершение: собирает summary из последних tool_results
LLM не вызвала submit_step после 2 ретраев SSE error с кодом writer_no_submit, step помечается failed, доступен retry
state_validator отклонил patch 3 раза подряд ORC добавляет в system message "Ваши предыдущие изменения отклонены валидатором. Проверь аргументы."
Embeddings API недоступен rag_query возвращает пустой список + warning в лог. rag_add сохраняет запись с embedding_status='pending', фоновый индексатор app/workers/embedding_indexer.py повторит через 30 секунд. После 3 неудачных попыток запись переходит в embedding_status='failed', доступна для ручного retry через POST /api/admin/embeddings/retry-failed.
Qdrant недоступен rag_query возвращает пустой список + error в лог. rag_add сохраняет запись с embedding_status='pending'. Health-check qdrant в /api/health становится false. Фоновый воркер блокирует обработку новых записей до восстановления Qdrant. Игровая сессия продолжается — RAG опционален, не критичный.
Размерность эмбеддингов не совпадает с размерностью Qdrant-коллекции Backend логирует error при upsert. Админу показывается баннер: «Размерность модели (X) не совпадает с коллекцией (Y). [Пересоздать коллекцию и переиндексировать]». Кнопка запускает POST /api/admin/embeddings/recreate-collections (с подтверждением).
DB connection lost mid-transaction Transaction rollback, SSE error с code=db_error. Step помечается failed.

17. Roadmap реализации (по спринтам)

Дорожная карта разбита на 8 спринтов по 2 недели. Каждый спринт заканчивается demoable deliverable. После каждого спринта — ретроспектива и обновление этого ТЗ если выявлены новые требования.

Sprint 1: Инфраструктура и БД (Foundation)

Цель: Рабочий docker-compose с пустым приложением, развёрнутые PostgreSQL + Qdrant со всеми таблицами/коллекциями, миграции, seed-данные.

TDD-циклы:

  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.

Приоритеты и зависимости

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. Ссылки и референсы

Приложение B. Шаблон worklog.md записи

---
Task ID: <task id, e.g. S5-3>
Agent: <agent name/ID>
Task: <краткое описание задачи>

Work Log:
- Прочитал ТЗ разделы 5, 8, 9.3
- Прочитал worklog — предыдущие задачи S5-1, S5-2 завершены
- Написал failing тест test_orchestrator_phase1_calls_env_update
- Реализовал минимально: ToolRegistry.execute, env_update tool
- Тест зелёный
- Рефакторинг: вынес _resolve_path в общий utils
- Все тесты зелёные, coverage 85%

Stage Summary:
- Реализован Phase 1 orchestrator с tool execution loop
- Добавлен MockLLMClient для replay-тестирования
- Создана replay-фикстура orchestrator_basic.json
- Изменена схема: добавлена step_tool_calls таблица (миграция 003)
- Open вопрос: нужно ли ограничение на количество одновременных tool calls в одном response?

Приложение C. Глоссарий сокращений

Сокращение Расшифровка
TZ Техническое задание
TDD Test-Driven Development
GM Game Master
SSE Server-Sent Events
RAG Retrieval-Augmented Generation
JWT JSON Web Token
RBAC Role-Based Access Control
NFR Non-Functional Requirement
DoD Definition of Done
ADR Architecture Decision Record
ORM Object-Relational Mapping
ASGI Asynchronous Server Gateway Interface
SPA Single Page Application
UX User Experience
FK Foreign Key
PK Primary Key
UK Unique Key