Небольшое веб-приложение превращает обычное сообщение жителя многоквартирного дома в компактную, но достаточную заявку для управляющей организации. Пользователь описывает проблему своими словами, при необходимости указывает известные последствия и желаемые действия, получает готовый текст и копирует его в удобный канал подачи.
Репозиторий содержит рабочий модульный монолит. Форма, HTTP API и универсальный
OpenAiCompatibleGateway реализованы. Yandex AI — одна из поддерживаемых
конфигураций этого gateway, а не отдельный транспорт.
LLM возвращает внутренний JSON-черновик, который приложение строго валидирует и
детерминированно преобразует в результат с полями title, body и warnings.
Невалидный или частичный ответ модели не показывается как готовая заявка.
Коннектор поддерживает совместимые подмножества Chat Completions и Responses
API. Для Chat Completions текст заявки читается из
choices[0].message.content. Для Responses API gateway нормализует
Yandex-compatible и стандартный сырой HTTP-ответ без SDK провайдера.
Упрощённый Yandex-compatible ответ с непустым output_text может не содержать
status, а стандартный вложенный ответ принимается только при status: completed.
Создайте файл .env в корне проекта на основе .env.example.
Протокол выбирается через LLM_API_PROTOCOL. Поддерживаются значения
chat-completions и responses. Для обратной совместимости отсутствие
переменной означает chat-completions, но в новых конфигурациях её следует
указывать явно. URL и имя модели не используются для определения протокола.
Встроенная конфигурация Yandex AI использует endpoint
https://ai.api.cloud.yandex.net/v1/chat/completions и модель YandexGPT для
Chat Completions. Для Responses API используются endpoint
https://ai.api.cloud.yandex.net/v1/responses и Alice AI LLM Flash.
Произвольный OpenAI-compatible провайдер настраивается полными URL, моделью,
схемой авторизации и выбранным протоколом.
Выбранные endpoint и модель должны поддерживать Structured Outputs: для
chat-completions — через response_format с типом json_schema, для
responses — через text.format с типом json_schema. Оба протокола используют
одну строгую JSON Schema с strict: true. Fallback на свободный текст,
json_object или повторный запрос отсутствует, поэтому провайдер без этой
поддержки несовместим с текущим gateway.
HOST и PORT по умолчанию равны 0.0.0.0 и 3000. Обычно их менять не
нужно.
Полный практический шаблон и пометки обязательных/опциональных переменных
смотрите в .env.example.
Без API-ключа или при неполной конфигурации реальный gateway не создаётся.
Приложение использует DisabledLlmGateway, а API возвращает контролируемый
ответ 503 без фиктивной заявки.
После успешной серверной валидации POST /api/generate проверяет
внутрипроцессные лимиты до обращения к LlmGateway. По умолчанию один IP-адрес
может выполнить не более 3 допущенных попыток за скользящие 60 секунд, а один
браузерный клиент — не более 20 допущенных попыток за календарные сутки UTC.
Одновременно для одного браузерного клиента выполняется не более одной
генерации.
Браузерный клиент определяется случайным непрозрачным UUID в подписанной
HTTP-only cookie uo_generation_client. Cookie не содержит пользовательские
данные. При отсутствующем, неподписанном или повреждённом значении приложение
подготавливает новый UUID, но устанавливает его только после допуска запроса
limiter. Отклонённый запрос не получает replacement cookie. Для HTTP-сценария
локальной разработки cookie работает без атрибута Secure, а для HTTPS-запроса
получает его автоматически.
Установленный клиент занимает active-слот по собственному UUID. Для запроса без
валидной cookie применяется консервативная временная проверка: пока с того же
штатного request.ip выполняется любая генерация, новый запрос отклоняется.
Разные установленные клиенты за одним IP могут выполнять генерации параллельно.
Лимиты настраиваются переменными GENERATION_IP_REQUEST_LIMIT,
GENERATION_IP_WINDOW_MS, GENERATION_CLIENT_DAILY_LIMIT и
GENERATION_RATE_LIMIT_STATE_CAPACITY. Доверие к reverse proxy выключено по
умолчанию: без GENERATION_TRUSTED_PROXIES Fastify получает trustProxy: false.
Переменная принимает разделённый запятыми allowlist буквальных IPv4, IPv6 и
CIDR, например
192.0.2.10,198.51.100.0/24,2001:db8::10,2001:db8:1::/64. DNS-имя, *, число
hop, пустой элемент и некорректный адрес останавливают запуск. Приложение
использует штатные request.ip и request.protocol Fastify и не разбирает
forwarding-заголовки самостоятельно.
В allowlist нужно указывать только фактические адреса или сети доверенных proxy
и обновлять его при любом изменении proxy-цепочки. Backend не должен быть
доступен публичному клиенту в обход proxy, а proxy обязан перезаписывать
X-Forwarded-For и X-Forwarded-Proto данными фактического соединения, а не
сохранять пользовательские значения. Настройка приложения не заменяет сетевое
ограничение доступа к backend.
Для миграции удалите legacy-переменную GENERATION_TRUST_PROXY и задайте явный
allowlist в GENERATION_TRUSTED_PROXIES. Наличие старой переменной считается
ошибкой конфигурации и останавливает запуск.
Для подключённого LLM обязателен
GENERATION_CLIENT_COOKIE_SECRET длиной не менее 32 символов. Отсутствующая или
некорректная конфигурация защиты не запускает приложение с реальным gateway.
При отключённом gateway локальный запуск остаётся доступен без секрета:
используется случайный эфемерный секрет процесса.
Отказ любого клиентского лимита возвращает 429 с публичным кодом
rate_limit_exceeded. Retry-After передаётся только для срока скользящего
IP-окна или до следующей границы суток UTC, когда срок можно вычислить точно.
После успешной CAPTCHA и непосредственно перед gateway приложение проверяет
общий предохранитель. Для подключённого LLM необходимо явно задать
GENERATION_ENABLED=true, GENERATION_GLOBAL_DAILY_LIMIT и
GENERATION_GLOBAL_CONCURRENCY_LIMIT. Оба лимита принимают только
положительные безопасные целые числа. GENERATION_ENABLED=false аварийно
запрещает новые вызовы. Неполная или некорректная конфигурация не запускает
реальный gateway. Локальный zero-config запуск с DisabledLlmGateway остаётся
доступным.
Дневной лимит выбирают по худшей допустимой стоимости одной генерации:
дневной лимит =
допустимый расход за сутки /
максимальная допустимая стоимость одной генерации
Предохранитель ограничивает массовый расход, но не является точным финансовым
бюджетом. Значения не отражают provider usage и не заменяют операторские отчёты.
Отключение, исчерпанный дневной предел и занятая общая ёмкость возвращают один
ответ 503 generation_unavailable без значений лимитов и внутренней причины.
Счётчики находятся только в одном процессе, ограничены по размеру и сбрасываются при перезапуске. Redis и распределённая синхронизация не используются, поэтому несколько экземпляров приложения не имеют общего лимита. Limiter хранит только IP-ключи, технические идентификаторы, временные отметки и счётчики без пользовательских текстов.
Режим SmartCaptcha задаётся через SMARTCAPTCHA_MODE: значение disabled
оставляет локальный сценарий без CAPTCHA, а required требует одновременно
SMARTCAPTCHA_CLIENT_KEY и SMARTCAPTCHA_SERVER_KEY. Для подключённого LLM
режим должен быть указан явно. Отсутствие переменной допускается только при
DisabledLlmGateway, чтобы документированный локальный запуск без ключей
оставался рабочим.
Браузер получает узкую публичную конфигурацию через
GET /api/captcha/config. Отключённый режим возвращает только
{"required":false}, обязательный — required и публичный клиентский ключ.
Серверный ключ не включается в этот ответ, HTML или browser JavaScript.
В обязательном режиме browser JavaScript динамически загружает официальный скрипт SmartCaptcha расширенным методом, создаёт невидимый виджет и сохраняет стандартный shield. CAPTCHA запускается только после успешной клиентской валидации. Один непустой callback-токен создаёт один запрос генерации, после любого результата виджет сбрасывается, а следующая попытка требует новый токен. Некорректная публичная конфигурация или ошибка загрузки скрипта не приводит к незащищённому запросу.
Сервер отделяет captchaToken от предметного ввода на HTTP-границе. После
успешного допуска limiter он отправляет токен, серверный ключ и штатный
request.ip в SmartCaptcha как application/x-www-form-urlencoded. Timeout,
сетевая ошибка, non-2xx, слишком большой или некорректный ответ и неоднозначный
status: "ok" с пустым host трактуются fail closed. Автоматических повторов
нет. status: "failed" возвращает 400 captcha_failed, техническая
недоступность — 503 captcha_unavailable. Ни один отказ CAPTCHA не вызывает
LlmGateway.
Репозиторий содержит только программную интеграцию и тестовые заглушки. Создание реального ресурса, установка ключей, публичное включение и ручная сквозная проверка относятся к issue #62. Если перед приложением используется reverse proxy, до публичного включения необходимо настроить его allowlist, перезапись forwarding-заголовков и сетевое ограничение доступа к backend.
apps/web— Fastify-сервер, JSON API и статический интерфейсpackages/core— доменные схемы, типы и порт генерацииpackages/llm— адаптеры порта генерации (DisabledLlmGateway,OpenAiCompatibleGateway)docs— продуктовые правила, архитектура и ADRscripts— проверки, общие для репозитория
Основная команда локального запуска требует Docker:
make composeПриложение будет доступно по адресу http://localhost:3000.
Необходим Docker Compose 2.24.0 или новее: корневой .env подключается как
необязательный env-файл. Поэтому контейнер запускается и в режиме заглушки, а
при наличии .env получает заданные LLM_* переменные. Файл .env исключён из
контекста сборки и репозитория.
Для разработки без контейнера нужны Node.js 26.3.0 и pnpm 11.12.0:
pnpm install --frozen-lockfile
pnpm devРучной smoke-check последовательно выполняет один платный LLM-запрос для каждого сценария из общего набора fixtures. Сейчас в наборе семь сценариев, поэтому полный запуск выполняет не более семи запросов:
pnpm smoke:llmПеред запуском настройте провайдера в локальном .env. Не передавайте ключи в
командной строке и не сохраняйте вывод smoke-check в репозитории. Команда
проверяет конфигурацию до первого запроса, запускается только вручную и не входит
в CI или pnpm check.
Автоматическая часть проверяет:
- соответствие outcome сценарию
- публичный контракт результата
- ожидаемое наличие warnings и отсутствие их текста в body
- отдельный раздел
Прошу: - от одного до трёх требований и их последовательную нумерацию
Для schema-valid результата команда печатает в stdout исходный синтетический
ввод, нормализованный публичный результат, mustPreserveFacts и
mustNotInvent до остальных структурных проверок. Поэтому отчёт остаётся
доступным, даже если формат Прошу: или наличие warnings оказались неверными.
Содержимое результата, не прошедшего публичную Zod-схему, не выводится.
В ручной смысловой проверке нужно убедиться, что:
- значимые факты сохранены
- домыслы и неподтверждённые категории людей не добавлены
- новые требования без основания не появились
- желаемые действия не смешаны с описанием проблемы
- требования соответствуют исходному вводу
Строка Автоматически прошли считает только сценарии, завершившие все
автоматические проверки. Код завершения 0 означает только успешное прохождение
автоматических проверок. Он не заменяет ручную смысловую проверку результата.
Код 1 означает отсутствующую конфигурацию, недоступность провайдера, ошибку
запроса, неожиданный outcome или нарушение проверяемого контракта. Обычная
ошибка сценария не останавливает остальные сценарии, а общая недоступность
провайдера прекращает дальнейшие платные запросы.
При SIGINT или SIGTERM сервер прекращает работу через graceful shutdown.
Повторный сигнал не запускает параллельное закрытие. Если app.close() не
завершится за 10 секунд, процесс принудительно остановится с ненулевым кодом.
Запустите приложение и повторите проверку при ширине viewport около 360 px и не менее 1280 px:
- Нет горизонтальной прокрутки и обрезанного содержимого
- Все обязательные и необязательные поля доступны
- Клиентская валидация показывает ошибку для некорректного ввода
- Во время отправки кнопка недоступна и повторный submit не запускается
- Результат и предупреждения читаемы и отделены друг от друга
- Предупреждения не выглядят частью копируемого текста заявки
- Копирование переносит только текст заявки без предупреждений
- Клавиатурная навигация проходит по элементам в понятном порядке
- Фокус на интерактивных элементах остаётся видимым
pnpm dev— запустить приложение для разработкиpnpm build— собрать production-версиюpnpm start— запустить собранное приложениеpnpm smoke:llm— вручную проверить все fixtures реальными LLM-запросамиpnpm lint— проверить код с Biomepnpm lint:md— проверить Markdown и окончания файловpnpm format— отформатировать поддерживаемые файлыpnpm format:check— проверить форматированиеpnpm typecheck— проверить типы TypeScriptpnpm test— запустить unit-тестыpnpm check— выполнить полный набор проверок
Команды также доступны через одноимённые цели make.