Files
tf_docs/llm_project/docs/ASSISTANT_ARCHITECTURE.md

20 KiB
Raw Permalink Blame History

AI-ассистент документации Nubes — полная документация

Дата: 2026-10-07 Обновлено: 2026-10-12 (правки: поиск *_sub_user, per-call таймаут ЛЛМ; коммит 12d29ee). Статус: работает на проде (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/<stand>/search/search_index.json. Включён на всех трёх стендах:

Стенд URL Виджет Эндпоинт
nubes-test /nubes-test/ ✅ ✅
nubes-dev /nubes-dev/ ✅ ✅
nubes (prod) /nubes/ ✅ ✅

Каждый стенд отвечает по своему индексу. Кэш индекса/ответов и лимиты ключуются по стенду.


2. Архитектура

Браузер (виджет assistant.js)
  │ POST /<stand>/_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/<stand>/search/search_index.json (ВМ 213 раздаёт статику MkDocs).


3. Файлы и ответственность

tf_docs (backend)

Файл Что делает
server.js HTTP-сервер: прокси GET/HEAD на ВМ + ветка POST /<stand>/_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 /<stand>/_api/assist

Запрос: {"question": "строка"} (Content-Type: application/json, тело ≤ 2 КБ).

Ответ 200:

{
  "answer": "ответ по шаблону Роль/Задача/Контекст/Требования/Ограничения",
  "sources": [{"title": "...", "url": "/<stand>/..."}],
  "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 не выдал обязательные заголовки
llm_timeout 504 вызов ЛЛМ превысил ASSIST_LLM_TIMEOUT_MS (с 2026-10-12)
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 таймаут одного вызова ЛЛМ; применяется с 2026-10-12 (код llm_timeout → 504)
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 /<stand>/<location>), 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). Суффиксы-подресурсы перебираются по убыванию длины (KNOWN_RESOURCE_SUBS); иначе короткий _user затенял _sub_user (nubes_s3_sub_user → s3_sub вместо s3) — исправлено 2026-10-12.
  • Предтокенизация: токены всех 823 документов считаются один раз при загрузке индекса (поиск ~6 мс вместо ~1.2 с).
  • Списки: listResourceTypes() (56 nubes_*) и resourcePages() (35 страниц) считаются один раз.

8. Надёжность

  • Повторы этапа 2: до 2 попыток, при обрезке ответа и транзиентных ошибках ЛЛМ (кроме отмены клиентом) — с удвоением бюджета.
  • Дедлайн 40 с: AbortController прерывает вызовы ЛЛМ.
  • Таймаут одного вызова ЛЛМ: ASSIST_LLM_TIMEOUT_MS (25 с, с 2026-10-12) — реализован в llmChat; покрывает вызов целиком, включая чтение тела. Отдаётся кодом llm_timeout (504) и отличается от отмены клиентом (aborted, 499).
  • Отмена при закрытии соединения: 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 клиентский сбой сети (не бэкенд)
2026-10-12 62/62 0 после правок (поиск _sub_user + per-call таймаут); файл tests/RESULTS_AFTER_FIX.{json,md}

Задержки (5-й прогон): p50 7.7 с, p95 23 с, max 40.8 с — доминирует LLM (gpt-oss-120b под нагрузкой), наш код ≈ 6 мс поиск + кэш. Задержки (2026-10-12): p50 5.9 с, p95 10.8 с, max 12.4 с.

Разбор: 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.