From fe9364532cc5556f08c4f16893f5782392ec0b66 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Wed, 7 Oct 2026 19:09:48 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B0=D1=81=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=20(=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D1=80=D0=B0,=20API,=20=D0=BF=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D0=BA,=20=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C,=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE?= =?UTF-8?q?=D0=B9,=20=D1=82=D0=B5=D1=81=D1=82=D1=8B,=20=D1=8D=D0=BC=D0=B1?= =?UTF-8?q?=D0=B5=D0=B4=D0=B4=D0=B8=D0=BD=D0=B3=D0=B8=20v2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_project/docs/ASSISTANT_ARCHITECTURE.md | 285 +++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 llm_project/docs/ASSISTANT_ARCHITECTURE.md diff --git a/llm_project/docs/ASSISTANT_ARCHITECTURE.md b/llm_project/docs/ASSISTANT_ARCHITECTURE.md new file mode 100644 index 0000000..7434745 --- /dev/null +++ b/llm_project/docs/ASSISTANT_ARCHITECTURE.md @@ -0,0 +1,285 @@ +# 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-совместимый интерфейс). + +--- + +## 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` и т.д. +- **Буст по типу страницы**: страница ресурса ×2.0, руководство (`_manual`) ×0.6, + служебная (`curated`/`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).