Skip to content

Repository files navigation

Генератор заявок для УО

Небольшое веб-приложение превращает обычное сообщение жителя многоквартирного дома в компактную, но достаточную заявку для управляющей организации. Пользователь описывает проблему своими словами, при необходимости указывает известные последствия и желаемые действия, получает готовый текст и копирует его в удобный канал подачи.

Текущий статус

Репозиторий содержит рабочий модульный монолит. Форма, 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.

Настройка LLM-провайдера

Создайте файл .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, когда срок можно вычислить точно.

Общий предохранитель LLM-вызовов

После успешной 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 задаётся через 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 — продуктовые правила, архитектура и ADR
  • scripts — проверки, общие для репозитория

Локальный запуск

Основная команда локального запуска требует 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 секунд, процесс принудительно остановится с ненулевым кодом.

Ручной browser smoke-check

Запустите приложение и повторите проверку при ширине viewport около 360 px и не менее 1280 px:

  • Нет горизонтальной прокрутки и обрезанного содержимого
  • Все обязательные и необязательные поля доступны
  • Клиентская валидация показывает ошибку для некорректного ввода
  • Во время отправки кнопка недоступна и повторный submit не запускается
  • Результат и предупреждения читаемы и отделены друг от друга
  • Предупреждения не выглядят частью копируемого текста заявки
  • Копирование переносит только текст заявки без предупреждений
  • Клавиатурная навигация проходит по элементам в понятном порядке
  • Фокус на интерактивных элементах остаётся видимым

Команды

  • pnpm dev — запустить приложение для разработки
  • pnpm build — собрать production-версию
  • pnpm start — запустить собранное приложение
  • pnpm smoke:llm — вручную проверить все fixtures реальными LLM-запросами
  • pnpm lint — проверить код с Biome
  • pnpm lint:md — проверить Markdown и окончания файлов
  • pnpm format — отформатировать поддерживаемые файлы
  • pnpm format:check — проверить форматирование
  • pnpm typecheck — проверить типы TypeScript
  • pnpm test — запустить unit-тесты
  • pnpm check — выполнить полный набор проверок

Команды также доступны через одноимённые цели make.

Документация

About

No description, website, or topics provided.

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages