337 lines
20 KiB
Markdown
337 lines
20 KiB
Markdown
# 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:**
|
||
```json
|
||
{
|
||
"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`.
|