# AI-ассистент документации Nubes — полная документация Дата: 2026-10-07 Статус: **работает на проде** (`https://tf-docs.nodejsk8s.dev.nubes.ru/nubes-test/`). Репозитории: `tf_docs` (backend), `tf_provider` (виджет + сборка сайта). --- ## 1. Что это Помощник на сайте документации: пользователь описывает задачу своими словами, получает ответ строго по шаблону со ссылками на страницы документации. Это **не свободный чат**, а **RAG-пайплайн**: сначала находится релевантный фрагмент документации, затем LLM пересказывает его в фиксированном формате. Модель: `gpt-oss-120b` (через `api.aillm.ru`, OpenAI-совместимый интерфейс). --- ## 1.1 Стенды Ассистент **stand-aware**: `stand` берётся из первого сегмента пути, индекс — из `VM_DOCS_BASE_URL//search/search_index.json`. Включён на всех трёх стендах: | Стенд | URL | Виджет | Эндпоинт | |---|---|---|---| | `nubes-test` | `/nubes-test/` | ✅ | ✅ | | `nubes-dev` | `/nubes-dev/` | ✅ | ✅ | | `nubes` (prod) | `/nubes/` | ✅ | ✅ | Каждый стенд отвечает по **своему** индексу. Кэш индекса/ответов и лимиты ключуются по стенду. --- ## 2. Архитектура ``` Браузер (виджет assistant.js) │ POST //_api/assist {"question": "..."} ▼ tf_docs (Node.js, кластер nodejsk8s) [orchestrator.js] ├─ validateInput (415/413/400) [assist.js] ├─ rate-limit по IP (429 + Retry-After) [assist.js] ├─ кэш ответов (ключ = stand+нормализованный вопрос) ├─ дедуп одинаковых запросов (inflight) ├─ семафор (ASSIST_MAX_CONCURRENT=4, иначе 503) ├─ общий дедлайн 40 с (AbortController) ├─ Этап 1: LLM → resource_type [stage1.js] ├─ Поиск: search_index.json → top-K [search.js] ├─ Объединение результатов (П.8) [stage2.js] └─ Этап 2: LLM → ответ по шаблону [stage2.js] ▼ Ответ: {answer, sources[], meta} ``` Индекс берётся с `VM_DOCS_BASE_URL//search/search_index.json` (ВМ 213 раздаёт статику MkDocs). --- ## 3. Файлы и ответственность ### `tf_docs` (backend) | Файл | Что делает | |---|---| | `server.js` | HTTP-сервер: прокси GET/HEAD на ВМ + ветка `POST //_api/assist` **до** проверки 405. Грузит `orchestrator.js` в try/catch (не роняет статику). | | `assist.js` | Каркас: `validateInput`, `createRateLimiter`, `createSemaphore`, `llmChat`, `jsonError`, `normalizeQuery`. Читает env-переменные. | | `search.js` | Лексический поиск: токенизация, HTML-очистка, стоп-слова, транслит, буст страницам ресурсов, предтокенизация, списки `nubes_*`. | | `stage1.js` | Этап 1: извлечение `resource_type` через LLM (`response_format: json_object`), сверка со списком реальных ресурсов. | | `stage2.js` | Объединение результатов поиска (П.8) + этап 2: генерация ответа по строгому шаблону с повторами. | | `orchestrator.js` | Связка пайплайна: кэш индекса, дедлайн, логирование, сбор токенов, ответ с `meta`. | | `Dockerfile` | Сборка образа (`node:18-alpine`), копирует все модули. | | `llm_project/tests/` | Тест-прогон, redeploy-скрипт, разбор результатов. | ### `tf_provider` (виджет + сайт) | Файл | Что делает | |---|---| | `docs/30_registry/javascripts/assistant.js` | Виджет (FAB «LLM», панель, вопрос/ответ, таймер, источники). ES5, IIFE, без зависимостей, вывод только через `textContent`. | | `docs/30_registry/assets/extra.css` | Стили виджета. | | `mkdocs.yml` | Подключение `assistant.js` в `extra_javascript`. | --- ## 4. Контракт API ### `POST //_api/assist` **Запрос:** `{"question": "строка"}` (Content-Type: application/json, тело ≤ 2 КБ). **Ответ 200:** ```json { "answer": "ответ по шаблону Роль/Задача/Контекст/Требования/Ограничения", "sources": [{"title": "...", "url": "//..."}], "meta": { "model": "gpt-oss-120b", "resource_type": "nubes_... или null", "stage1_ok": true, "elapsed_ms": 12000, "index_ms": 86, "stage1_ms": 1200, "search_ms": 6, "stage2_ms": 8300, "tokens": 2868, "prompt_tokens": 2105, "completion_tokens": 763 } } ``` **Ошибки (JSON `{"error": code, "message": ...}`):** | Код | HTTP | Смысл | |---|---|---| | 400 | 400 | невалидный JSON / пустой вопрос | | 413 | 413 | тело > 2 КБ | | 415 | 415 | Content-Type не `application/json` | | `rate_limited` | 429 | превышен лимит (заголовок `Retry-After`) | | `busy` | 503 | семафор исчерпан (`Retry-After: 5`) | | `bad_llm_output` | 502 | LLM не выдал обязательные заголовки | | `index_unavailable` | 503 | не удалось загрузить индекс | --- ## 5. Переменные окружения (jsonEnv в ЛК) | Имя | По умолчанию | Назначение | |---|---|---| | `VM_DOCS_BASE_URL` | — (обязательна) | `http://5.172.178.213` (источник статики и индекса) | | `LLM_BASE_URL` | — | `https://api.aillm.ru` | | `LLM_API_KEY` | — | ключ ЛЛМ (Secret, не логировать) | | `LLM_MODEL` | `gpt-oss-120b` | модель | | `ASSIST_MAX_CONCURRENT` | `4` | семафор одновременных вызовов ЛЛМ | | `ASSIST_TOTAL_DEADLINE_MS` | `40000` | общий бюджет запроса | | `ASSIST_LLM_TIMEOUT_MS` | `25000` | таймаут одного вызова ЛЛМ | | `ASSIST_RATE_PER_WINDOW` | `6` | лимит запросов на IP | | `ASSIST_RATE_WINDOW_MS` | `60000` | окно rate-limit | | `ASSIST_REPLICAS` | `1` | делитель лимита на реплики | | `ASSIST_MAX_BODY` | `2048` | макс. размер тела | | `ASSIST_INDEX_TTL_MS` | `600000` | TTL кэша индекса (10 мин) | | `ASSIST_TRUSTED_PROXY_CIDR` | — | CIDR доверенного proxy для `X-Forwarded-For` | --- ## 6. Пайплайн по шагам 1. **Валидация** (`validateInput`): Content-Type, Content-Length ≤ 2 КБ, потоковый счётчик байт, JSON, непустой `question`. 2. **Rate-limit**: скользящее окно по IP, лимит делится на число реплик. 3. **Кэш**: ключ `stand + "\u0000" + normalizeQuery(question)`. 4. **Дедуп**: одинаковый запрос в работе — ждёт результат первого (счётчики «ожидающие» / «держащие»), обрыв одного клиента не отменяет общий вызов. 5. **Семафор**: `ASSIST_MAX_CONCURRENT`; сверх — 503 + `Retry-After`. 6. **Этап 1** (`stage1.js`): LLM определяет `resource_type` из списка реальных `nubes_*` (список из индекса). Не из списка → отбрасывается. Пустой `content` → повтор с x2 `max_tokens`. 7. **Поиск** (`search.js`): BM25-подобный, токены + транслит + буст страницам ресурсов. Объединение (П.8): поиск по исходному запросу + по `resource_type`, повторы по `location`, оценка = max, страница ресурса всегда первой. 8. **Этап 2** (`stage2.js`): LLM генерирует ответ по строгому шаблону «Роль/Задача/Контекст/Требования/Ограничения». Нет заголовков → повтор с x2 `max_tokens` (до 4096). Ошибка осталась → 502. 9. **Ответ**: `answer`, `sources` (только валидные URL `//`), `meta` (время этапов, токены). --- ## 7. Поиск — детали (v1, лексический) - **Токенизация**: lowercase, удаление HTML, разбиение на токены `[a-zа-яё0-9_]+`, отбрасывание стоп-слов (рус. + англ.). - **Транслит** (`TRANSLIT` в `search.js`): «постгрес»→`postgres`, «бакет»→`bucket`, «топик»→`topic` и т.д. - **Буст по типу страницы** (приоритет — «примеры в первую очередь»): - `curated/*` (готовые примеры, проверенные на реальных деплоях) — **×2.4**; - страница ресурса — ×2.0; - руководство (`_manual`) — ×0.6; - служебная (`30_registry`) — ×0.4. - **Подресурс → родитель**: `nubes_postgres_database` при поиске дополняется токеном `postgres` (`resourceBaseSegment`). - **Предтокенизация**: токены всех 823 документов считаются один раз при загрузке индекса (поиск ~6 мс вместо ~1.2 с). - **Списки**: `listResourceTypes()` (56 `nubes_*`) и `resourcePages()` (35 страниц) считаются один раз. --- ## 8. Надёжность - **Повторы этапа 2**: до 2 попыток, при обрезке ответа и транзиентных ошибках ЛЛМ (кроме отмены клиентом) — с удвоением бюджета. - **Дедлайн 40 с**: `AbortController` прерывает вызовы ЛЛМ. - **Отмена при закрытии соединения**: `req.on('close')` → abort (если нет других держащих). - **Кэш индекса**: TTL 10 мин; при сбое загрузки — работаем на свежем кэше. - **Кэш ответов**: in-memory Map (без TTL сейчас, чистится только перезапуском). - **Логирование**: однострочный JSON в stdout — `status, stand, question_len, total_ms, index_ms, stage1_ms, search_ms, stage2_ms, tokens`. Текст вопросов НЕ логируется. --- ## 9. Безопасность - **Prompt injection**: ответ строго по шаблону; вопросы вне документации → шаблонный отказ в разделе «Ограничения». - **Свободная ЛЛМ**: жёсткий `max_tokens`, 502 при нарушении шаблона. - **`sources`**: только из индекса, отбрасываются URL с `//`, `:`, `..`. - **XSS**: вывод в виджете только через `textContent`, ссылки — только относительные. - **Ключ**: не логируется, не попадает в ответ, ошибки маскируют его. - **SSRF**: URL индекса строится только из `VM_DOCS_BASE_URL` + проверенного `stand`. --- ## 10. Деплой ### `tf_docs` (backend) 1. `git push` в `terraform/tf_docs`. 2. Redeploy экземпляра в ЛК **или** `node llm_project/tests/redeploy.js` (Bearer из `tf_provider/secrets/test.token`, CFS-параметр `startupConfiguration`). ### `tf_provider` (виджет + сайт) 1. `git push` в `terraform/tf_provider`. 2. Сборка и публикация: `DOCS_SKIP_LINT=1 TOOLS/scripts/04_build_and_publish_docs.sh --profile TOOLS/config/test` → S3 `terraform-registry/docs/nubes-test/nubes/`. 3. Зеркало на ВМ 213: `ssh naeel@5.172.178.213 "mc mirror --overwrite --remove nubes/terraform-registry/docs/nubes-test/nubes/ /var/www/tf-docs/nubes-test/"` --- ## 11. Тестирование `llm_project/tests/run_e2e_live.js` — 62 вопроса по живому эндпоинту (учёт 429). Итог по прогонам (62 вопроса): | Прогон | 200 OK | Отказов | Причина и фикс | |---|---|---|---| | 1 | 56/62 | 6 | обрезка `max_tokens: 1024` → 2048 + повтор; баг `\b` с кириллицей | | 2 | 60/62 | 2 | + повтор при транзиентных ошибках | | 3 | 61/62 | 1 | + шаблон для вопросов вне документации | | 4 | **62/62** | **0** | — | | 5 | 61/62 | 1 | клиентский сбой сети (не бэкенд) | Задержки (5-й прогон): p50 7.7 с, p95 23 с, max 40.8 с — доминирует LLM (`gpt-oss-120b` под нагрузкой), наш код ≈ 6 мс поиск + кэш. Разбор: `llm_project/tests/ANALYSIS_bad_llm_output.md`. --- ## 12. Эмбеддинги + реранкер (v2) — детальный план (НЕ реализовано) ### Что это Семантический поиск: текст превращается в вектор из 1024 чисел моделью `user-bge-m3`; похожие по смыслу тексты дают близкие векторы. Сравнение — косинусное сходство. Ранжирование кандидатов — моделью `bge-reranker-v2-m3`. ### Что даст - Точное попадание на перефразах («поднять ВМ» = «создать виртуальную машину»). - Устойчивость к синонимам и русской морфологии, где лексический поиск промахивается. - Главный качественный скачок из оставшихся возможных улучшений. ### Как хранить результат - Один документ = 1024 float32 = 4 КБ. 823 документа ≈ **3.4 МБ**. - Хранить **в памяти процесса** (`Map: location → Float32Array`), рядом с кэшем индекса. - Отдельной БД/файла не нужно (диск эфемерный, БД противоречит «без зависимостей»). - Пересчитывается при перезапуске пода (разово ~30-60 с на 823 дока). ### Как реализовать (механика) 1. `POST /v1/embeddings` (модель `user-bge-m3`, батчем) для каждого документа при загрузке индекса → `Map`. 2. На запрос: эмбеддинг вопроса → косинусное сходство по всем докам → top-K. 3. Объединить с лексическим поиском по max (как уже сделано в `mergeSearchResults`). 4. Опционально: top-20 кандидатов → `bge-reranker-v2-m3` → точный порядок. ### Сложность и цена - **~50-100 строк** нового модуля `vectorSearch.js`, без новых npm-зависимостей (fetch уже есть). API проверен (`user-bge-m3` 1024 dims, `bge-reranker-v2-m3`). - **Цена**: разовые 823 эмбеддинга при старте + 1 эмбеддинг на вопрос (+реранкер ~0.3-1 с). - **Риск**: низкий (параллельно лексическому, объединение по max). - **Оценка трудозатрат**: ~полдня. ### Когда делать **Не сейчас.** Реальные пользователи ещё не тестировали — неизвестны реальные промахи. Рекомендация: выкатить v1 юзерам, собрать реальные вопросы, и уже по ним решать о внедрении эмбеддингов. --- ## 13. Известные ограничения и точки роста 1. Поиск лексический (нет семантики) — см. раздел 12. 2. `resource_type` определяется ~47/62; реальный промах — выбор подресурса (например `nubes_mariadb_database` вместо `nubes_mariadb`). Лечится few-shot примерами в промпте этапа 1. 3. Ответ жёстко по 5 заголовкам — безопасно, но не гибко (осознанный trade-off). 4. Кэш ответов без TTL/размерного лимита (in-memory Map). При долгой работе стоит добавить LRU + TTL. 5. `remoteIsInCidr` упрощён до точного совпадения/`/32` — если появится настоящий proxy-CIDR, доработать. 6. Один под (replicas=1) — лимиты/кэш не шарятся между репликами (принято в брифе 7.4). 7. Задержка доминируется LLM; ускоряется стримингом (SSE) или параллелизацией этапа 1 с поиском (v2). --- ## 14. Исправленный инцидент: сборка `nubes-dev` При публикации dev-документации сборка падала на YAML-ошибке nav: ``` - [DEPRECATED] Публичные IP адреса: ← невалидный YAML ``` **Причина:** генератор `TOOLS/docs-generator/internal/writers/writers.go` (`WriteNavFragment`) писал `ServiceDisplayName` без кавычек. У `vcexternalip` в dev имя = `[DEPRECATED] Публичные IP адреса` → ломал парсер. **Фикс (корень):** в генератор добавлен `yamlScalar()` — заголовки с спецсимволами (`[ ] : # & *` и др.) кавычатся. Затронул все стенды на будущее. **Разовый обход:** для немедленной публикации dev строка в `generated/dev/docs/_nav_fragment.yml` закавычена вручную (файл генерируемый, перезапишется корректно после фикса генератора). Коммит фикса: `tf_provider` `322235c`.