Files
tf_docs/llm_project/docs/ASSISTANT_ARCHITECTURE.md
T

337 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.