From c59f0245b5b1bfa120768250bcb903b1b2dfa3f7 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Wed, 7 Oct 2026 14:11:35 +0300 Subject: [PATCH] =?UTF-8?q?chore:=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D1=83=D0=BB=D0=B5=D0=B9=20+=20=D0=BF=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=88=D0=B0=D0=B3=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_project/PLAN_docs_ai_assistant_STEPS.md | 162 ++++++++ llm_project/golden/golden_questions.json | 225 ++++++++-- llm_project/probe/probe_aillm.js | 268 ++++++------ orchestrator.js | 432 ++++++++++---------- server.js | 2 +- stage1.js | 198 ++++----- 6 files changed, 808 insertions(+), 479 deletions(-) create mode 100644 llm_project/PLAN_docs_ai_assistant_STEPS.md diff --git a/llm_project/PLAN_docs_ai_assistant_STEPS.md b/llm_project/PLAN_docs_ai_assistant_STEPS.md new file mode 100644 index 0000000..412379e --- /dev/null +++ b/llm_project/PLAN_docs_ai_assistant_STEPS.md @@ -0,0 +1,162 @@ +# Пошаговый план реализации AI-ассистента документации + +Дата: 2026-10-07 +Кому: субагент DeepSeek Flash (исполнитель механических шагов) +Кто отвечает за интеграцию/проверку: ассистент (я) +Контекст: см. `BRIEF_docs_ai_assistant.md` (решения), `reviews/` (рецензия + диалог). +Рабочий каталог кода: `~/TF/tf_docs` (Node.js, без внешних зависимостей, node >= 18). + +> КАЖДЫЙ шаг ниже — отдельная порция для субагента Flash. +> Flash делает механику, я (ассистент) принимаю/интегрирую/проверяю. +> После каждого шага — коммит осмысленным сообщением, push только по команде владельца. + +--- + +## Шаг 0. Блокеры и рекомендации (утвердить у владельца) + +**Не код. Нужны решения владельца до начала.** +- Блокеры: 7.1 (путь `//_api/assist`), 7.2 (политика отправки данных в ЛЛМ), 7.3 (`X-Forwarded-For`/`ASSIST_TRUSTED_PROXY_CIDR`), 7.5 (не снят — уточнить), ротация ключа `api.aillm.ru`. +- Рекомендации одним списком: 7.4, 7.6, 7.7, 7.8, 7.9, 7.11. +- Приёмник: `llm_project/DECISIONS_v0.md` (фиксируем принятое). + +--- + +## Шаг 1. Live-проверка `api.aillm.ru` + +**Задача Flash:** написать одноразовый скрипт-пробник (НЕ часть `assist.js`) и вернуть факты. +Файл: `llm_project/probe/probe_aillm.js` (запуск вручную, результат — в `llm_project/probe/RESULTS.md`). + +Проверить и зафиксировать: +1. `response_format: {type: "json_object"}` — поддерживается? (даёт ли валидный JSON) +2. `stream: true` — какой формат чанков (`data:` SSE)? нужен ли нам вообще (v1 — нет). +3. `reasoning_effort: "low"` — принимается? есть ли `reasoning_content` в ответе? +4. Ответ на 429 — какие заголовки (`Retry-After`? `X-RateLimit-*`?), тело. +5. Размер контекста — до скольки токенов принимает запрос (увеличивая промпт, найти предел/ошибку). +6. Формат ошибки при пустом `content` / обрыве. +7. Сколько длится этап 1-типа запрос (засечь 3 образца) — для дедлайна 40 с. + +**Критерий готово:** `RESULTS.md` содержит таблицу ответов по всем 7 пунктам + пример сырого ответа (JSON) для этапов 1 и 2. + +**После шага 1:** финализируем `llmChat` (структуру разбора ответа). + +--- + +## Шаг 2. Каркас `assist.js` + +**Задача Flash:** создать `assist.js` (новый модуль), НЕ трогая `server.js`. +Модули по брифу: +- `validateInput(req)` — Content-Type `application/json`, Content-Length > 2048 → 413, чтение потока со счётчиком байт (> 2 КБ → 413), разбор JSON, поле `question` (непустая строка). +- rate-limit — локальная таблица ключ=IP (или IP/stand), периодическая чистка; лимит делится на число реплик (`ASSIST_REPLICAS`). +- семафор `ASSIST_MAX_CONCURRENT` = 4; сверх — 503 + `Retry-After: 5`, тело `{"error":"busy","message":"..."}`. +- объединение одинаковых запросов (ключ = `stand` + нормализованный запрос): два счётчика («ожидающие клиенты» / «держащие вызов»); обрыв клиента не отменяет общий вызов, пока есть держащие; отмена ЛЛМ только когда отключились все. +- `llmChat(messages, {max_tokens, controller})` — POST на `api.aillm.ru`, `AbortController`, разбор только `content`. +- общий дедлайн `ASSIST_TOTAL_DEADLINE_MS` = 40000; повтор этапа 1 только если запас > 15 с; `req.on('close')` → abort. +- кэш ответов (ключ = `stand` + нормализованный запрос), TTL. +- логирование: длина вопроса, `stand`, время этапов, статус, кэш, токены. Текст запросов — НЕ логировать. + +**Критерий готово:** модуль экспортирует обработчик, собирается без ошибок (`node -c assist.js`), покрыт беглыми юнит-пробами в `test/` (validateInput, семафор, дедуп-ключ). + +--- + +## Шаг 3. Эталонный набор вопросов + +**Задача Flash:** создать `llm_project/golden/golden_questions.json` — 20–30 вопросов. +Формат каждой записи: +```json +{"question": "...", "expected_pages": ["path/на_страницу"], "resource_type": "nubes_..."} +``` +Вопросы: по разным ресурсам (`nubes_*`), включая подресурсы (`_user`, `_database`, `_topic`), негативные (вопрос не про ресурс, вопрос с выдуманным ресурсом вроде `nubes_instance`). +**Критерий готово:** файл валиден, вопросы реалистичны (взяты из опубликованной документации стенда `nubes-test`). + +--- + +## Шаг 4. Индекс и поиск + +**Задача Flash:** реализовать загрузку и лексический поиск по `search_index.json`. +- Источник: `VM_DOCS_BASE_URL//search/search_index.json` (только этот путь). +- Снять формат индекса с реального стенда (см. шаг 7/8 верификации): структура записи, поля `location` (вид `page/#anchor`), `title`, `text` (HTML → удалить). +- Лекс. поиск: токенизация, BM25-подобный скор, top-K разделов, группировка по страницам. +- Собрать список реальных `nubes_*` из имён страниц (для этапа 1). +- Порог «не найдено» = «совпало >= N уникальных ключевых слов», N подобрать по эталону (шаг 3). +**Критерий готово:** на эталонном наборе поиск выдаёт ожидаемые страницы с приемлемой точностью (записать метрику). + +--- + +## Шаг 5. Этап 1 (извлечение `resource_type`) + +**Задача Flash:** реализовать этап 1. +- Системный промпт (рус.) со списком реальных `nubes_*` из индекса. +- Ответ: `resource_type`; если не в списке — отбрасывается (нет ресурса). +- Падение/таймаут этапа 1 — продолжить без него (только токен-поиск). +**Критерий готово:** на эталоне корректно извлекает/отбрасывает `resource_type`; выдуманные (`nubes_instance`) — отбрасываются. + +--- + +## Шаг 6. Объединение + этап 2 + +**Задача Flash:** реализовать объединение (П.8) и этап 2. +Объединение: +- Поиск по токенам исходного запроса — наравне с поиском после этапа 1. +- Страница ресурса — всегда первой в контекст (если `resource_type` в списке). +- Кандидаты объединяются, повторы по `location`, оценка = max из двух; контекст до K без повторов; страница ресурса входит в K. +- Падение этапа 1 — только токен-поиск, без обязательной страницы. +Этап 2: +- Промпт со строгим шаблоном «Роль/Задача/Контекст/Требования/Ограничения». +- Ответ без заголовков → 502 `bad_llm_output`. +- Жёсткий `max_tokens`. +- `sources` = `//` + `location`; отбрасывать `//`, `:`, `..`. +**Критерий готово:** полный сквозной ответ на эталоне, корректные `sources`. + +--- + +## Шаг 7. Маршрут в `server.js` + Dockerfile + README + +**Задача Flash (под контролем):** в `server.js` добавить POST-ветку `//_api/assist` **до** проверки 405 (строки 72–75). +- `stand` через `STAND_RE`. +- `assist.js` грузить в try/catch; ошибки → 500 JSON. +- Поведение статики и падение без `VM_DOCS_BASE_URL` — НЕ менять. +- Dockerfile: скопировать `assist.js` (+ при необходимости `lib/`). +- README: описать эндпоинт и переменные окружения (`ASSIST_*`, ключ). +**Критерий готово:** `node -c`, статика работает как раньше, POST отвечает JSON (200/4xx/5xx). + +--- + +## Шаг 8. Виджет (фронтенд) + +**Задача Flash:** написать виджет (встраиваемый JS/CSS/HTML или шаблон). +- `stand` из первого сегмента `location.pathname`; нет сегмента — не показывать. +- Плавающая кнопка на всех страницах. +- Под полем ввода: «не вводите ключи и пароли». +- Обращение к `//_api/assist`. +- Обработка 503 (`Retry-After`) — показ «занято, повторите». +**Критерий готово:** виджет открывается, шлёт запрос, показывает ответ и `sources`-ссылки. + +--- + +## Шаг 9. Итоговая оценка качества + +**Задача Flash:** прогнать эталонный набор (шаг 3) через полный пайплайн, собрать метрики. +- Точность `resource_type`, точность страниц, корректность `sources`, % 502/ошибок, задержки. +**Критерий готово:** отчёт `llm_project/golden/EVAL_*.md` с таблицей. + +--- + +## Шаг 10. Деплой — ТОЛЬКО по команде владельца. Ассистент сам НЕ деплоит. + +## Шаг 11. Документация (README финал, переменные, эксплуатация). + +--- + +## Порядок выполнения и зависимости + +1. Шаг 0 (утверждение) → блокер, ждать владельца. +2. Шаг 1 и Шаг 2 — параллельно (каркас не зависит от результата шага 1; `llmChat` финализируется после шага 1). +3. Шаг 3 → 4 → 5 → 6 (зависимы). +4. Шаг 7 → 8 (после 6). +5. Шаг 9 (оценка), Шаг 10 (деплой, по команде), Шаг 11 (документация). + +## Непроверенные допущения (снять на стенде) +- `proxy-read-timeout` nginx-ingress (шаг 1/верификация). +- Формат `search_index.json` (шаг 4). +- Пропускает ли ingress POST (`curl -X POST` на стенде) — отдельная верификация. +- Поддержка `reasoning_effort`/`response_format`/`stream`/429 у `api.aillm.ru` (шаг 1). diff --git a/llm_project/golden/golden_questions.json b/llm_project/golden/golden_questions.json index 40e6cf3..0e0606d 100644 --- a/llm_project/golden/golden_questions.json +++ b/llm_project/golden/golden_questions.json @@ -1,30 +1,197 @@ [ - {"question": "Как создать виртуальную машину в облаке Nubes?", "expected_pages": ["vc_vm_v3"], "resource_type": "nubes_vc_vm_v3"}, - {"question": "Как настроить PostgreSQL с автоматическим бэкапом?", "expected_pages": ["postgres"], "resource_type": "nubes_postgres"}, - {"question": "Как создать базу данных и пользователя в PostgreSQL?", "expected_pages": ["postgres"], "resource_type": "nubes_postgres_database"}, - {"question": "Как выдать виртуальной машине белый IP-адрес?", "expected_pages": ["vcexternalip"], "resource_type": "nubes_vcexternalip"}, - {"question": "Как поднять MongoDB с ежедневным бэкапом?", "expected_pages": ["mongodb"], "resource_type": "nubes_mongodb"}, - {"question": "Как завести пользователя и пароль для MongoDB?", "expected_pages": ["mongodb"], "resource_type": "nubes_mongodb_user"}, - {"question": "Как развернуть Redis для кэширования?", "expected_pages": ["redis"], "resource_type": "nubes_redis"}, - {"question": "Как создать MariaDB и привязать к ней отдельную базу?", "expected_pages": ["mariadb"], "resource_type": "nubes_mariadb"}, - {"question": "Как поднять ClickHouse и создать в нём таблицу?", "expected_pages": ["clickhouse"], "resource_type": "nubes_clickhouse_database"}, - {"question": "Как развернуть Kafka-кластер и создать топик?", "expected_pages": ["kafka"], "resource_type": "nubes_kafka_topic"}, - {"question": "Как завести пользователя Kafka с правами на конкретный топик?", "expected_pages": ["kafka"], "resource_type": "nubes_kafka_user"}, - {"question": "Как развернуть RabbitMQ для очередей задач?", "expected_pages": ["rabbitmq"], "resource_type": "nubes_rabbitmq"}, - {"question": "Как создать S3-бакет для хранения файлов?", "expected_pages": ["s3bucket", "s3"], "resource_type": "nubes_s3bucket"}, - {"question": "Как создать S3-хранилище и сублюзеров с ограниченными ключами?", "expected_pages": ["s3"], "resource_type": "nubes_s3_sub_user"}, - {"question": "Как развернуть Kubernetes-кластер в Shturval?", "expected_pages": ["k8s_sthutrval_cluster"], "resource_type": "nubes_k8s_sthutrval_cluster"}, - {"question": "Как установить Velero для бэкапа Kubernetes?", "expected_pages": ["k8s_velero"], "resource_type": "nubes_k8s_velero"}, - {"question": "Как развернуть OpenBao для хранения секретов в кластере?", "expected_pages": ["k8s_openbao"], "resource_type": "nubes_k8s_openbao"}, - {"question": "Как создать DNS-запись для своего домена?", "expected_pages": ["dnsrecord", "zones_v2"], "resource_type": "nubes_dnsrecord"}, - {"question": "Как создать организацию и vDC в VMware-облаке?", "expected_pages": ["vc_org", "vc_vdc"], "resource_type": "nubes_vc_vdc"}, - {"question": "Как настроить SNAT в NSX-T?", "expected_pages": ["vc_nsxt"], "resource_type": "nubes_vc_nsxt_snat"}, - {"question": "Как развернуть LLM-модель с секретами и командой?", "expected_pages": ["llm_ai"], "resource_type": "nubes_llm_ai"}, - {"question": "Как развернуть Nextcloud и настроить бэкап?", "expected_pages": ["nextcloud"], "resource_type": "nubes_nextcloud"}, - {"question": "Как развернуть Node.js приложение и сделать передеплой?", "expected_pages": ["nodejs"], "resource_type": "nubes_nodejs"}, - {"question": "Как задеплоить Flask-приложение и получить его URL?", "expected_pages": ["flask"], "resource_type": "nubes_flask"}, - {"question": "Как развернуть pgAdmin для управления базами?", "expected_pages": ["pgadmin"], "resource_type": "nubes_pgadmin"}, - {"question": "Как подключиться к API Terraform-провайдера Nubes?", "expected_pages": [], "resource_type": ""}, - {"question": "Какие версии провайдера сейчас доступны и как их обновлять?", "expected_pages": ["template"], "resource_type": ""}, - {"question": "Как создать nubes_instance с плавающим IP floating_ip?", "expected_pages": [], "resource_type": ""} -] + { + "question": "Как создать виртуальную машину в облаке Nubes?", + "expected_pages": [ + "vc_vm_v3" + ], + "resource_type": "nubes_vc_vm_v3" + }, + { + "question": "Как настроить PostgreSQL с автоматическим бэкапом?", + "expected_pages": [ + "postgres" + ], + "resource_type": "nubes_postgres" + }, + { + "question": "Как создать базу данных и пользователя в PostgreSQL?", + "expected_pages": [ + "postgres" + ], + "resource_type": "nubes_postgres_database" + }, + { + "question": "Как выдать виртуальной машине белый IP-адрес?", + "expected_pages": [ + "vcexternalip" + ], + "resource_type": "nubes_vcexternalip" + }, + { + "question": "Как поднять MongoDB с ежедневным бэкапом?", + "expected_pages": [ + "mongodb" + ], + "resource_type": "nubes_mongodb" + }, + { + "question": "Как завести пользователя и пароль для MongoDB?", + "expected_pages": [ + "mongodb" + ], + "resource_type": "nubes_mongodb_user" + }, + { + "question": "Как развернуть Redis для кэширования?", + "expected_pages": [ + "redis" + ], + "resource_type": "nubes_redis" + }, + { + "question": "Как создать MariaDB и привязать к ней отдельную базу?", + "expected_pages": [ + "mariadb" + ], + "resource_type": "nubes_mariadb" + }, + { + "question": "Как поднять ClickHouse и создать в нём таблицу?", + "expected_pages": [ + "clickhouse" + ], + "resource_type": "nubes_clickhouse_database" + }, + { + "question": "Как развернуть Kafka-кластер и создать топик?", + "expected_pages": [ + "kafka" + ], + "resource_type": "nubes_kafka_topic" + }, + { + "question": "Как завести пользователя Kafka с правами на конкретный топик?", + "expected_pages": [ + "kafka" + ], + "resource_type": "nubes_kafka_user" + }, + { + "question": "Как развернуть RabbitMQ для очередей задач?", + "expected_pages": [ + "rabbitmq" + ], + "resource_type": "nubes_rabbitmq" + }, + { + "question": "Как создать S3-бакет для хранения файлов?", + "expected_pages": [ + "s3bucket", + "s3" + ], + "resource_type": "nubes_s3bucket" + }, + { + "question": "Как создать S3-хранилище и сублюзеров с ограниченными ключами?", + "expected_pages": [ + "s3" + ], + "resource_type": "nubes_s3_sub_user" + }, + { + "question": "Как развернуть Kubernetes-кластер в Shturval?", + "expected_pages": [ + "k8s_sthutrval_cluster" + ], + "resource_type": "nubes_k8s_sthutrval_cluster" + }, + { + "question": "Как установить Velero для бэкапа Kubernetes?", + "expected_pages": [ + "k8s_velero" + ], + "resource_type": "nubes_k8s_velero" + }, + { + "question": "Как развернуть OpenBao для хранения секретов в кластере?", + "expected_pages": [ + "k8s_openbao" + ], + "resource_type": "nubes_k8s_openbao" + }, + { + "question": "Как создать DNS-запись для своего домена?", + "expected_pages": [ + "dnsrecord", + "zones_v2" + ], + "resource_type": "nubes_dnsrecord" + }, + { + "question": "Как создать организацию и vDC в VMware-облаке?", + "expected_pages": [ + "vc_org", + "vc_vdc" + ], + "resource_type": "nubes_vc_vdc" + }, + { + "question": "Как настроить SNAT в NSX-T?", + "expected_pages": [ + "vc_nsxt" + ], + "resource_type": "nubes_vc_nsxt_snat" + }, + { + "question": "Как развернуть LLM-модель с секретами и командой?", + "expected_pages": [ + "llm_ai" + ], + "resource_type": "nubes_llm_ai" + }, + { + "question": "Как развернуть Nextcloud и настроить бэкап?", + "expected_pages": [ + "nextcloud" + ], + "resource_type": "nubes_nextcloud" + }, + { + "question": "Как развернуть Node.js приложение и сделать передеплой?", + "expected_pages": [ + "nodejs" + ], + "resource_type": "nubes_nodejs" + }, + { + "question": "Как задеплоить Flask-приложение и получить его URL?", + "expected_pages": [ + "flask" + ], + "resource_type": "nubes_flask" + }, + { + "question": "Как развернуть pgAdmin для управления базами?", + "expected_pages": [ + "pgadmin" + ], + "resource_type": "nubes_pgadmin" + }, + { + "question": "Как подключиться к API Terraform-провайдера Nubes?", + "expected_pages": [], + "resource_type": "" + }, + { + "question": "Какие версии провайдера сейчас доступны и как их обновлять?", + "expected_pages": [ + "template" + ], + "resource_type": "" + }, + { + "question": "Как создать nubes_instance с плавающим IP floating_ip?", + "expected_pages": [], + "resource_type": "" + } +] \ No newline at end of file diff --git a/llm_project/probe/probe_aillm.js b/llm_project/probe/probe_aillm.js index e491805..cb624d1 100644 --- a/llm_project/probe/probe_aillm.js +++ b/llm_project/probe/probe_aillm.js @@ -24,163 +24,163 @@ const MODEL = process.env.LLM_MODEL || 'gpt-oss-120b'; // ── поиск ключа: первая строка, начинающаяся с sk-, после строки, содержащей 'https' ── function findApiKey() { - const lines = fs.readFileSync(KEY_FILE, 'utf8').split(/\r?\n/); - for (let i = 0; i < lines.length; i++) { - const t = lines[i].trim(); - if (t.toLowerCase().includes('https') || t.toLowerCase().includes('aillm')) { - // дальше ищем ближайший sk-... в следующих 5 строках - for (let j = i + 1; j < Math.min(lines.length, i + 6); j++) { - const k = lines[j].trim(); - if (k.startsWith('sk-')) return k; - } + const lines = fs.readFileSync(KEY_FILE, 'utf8').split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const t = lines[i].trim(); + if (t.toLowerCase().includes('https') || t.toLowerCase().includes('aillm')) { + // дальше ищем ближайший sk-... в следующих 5 строках + for (let j = i + 1; j < Math.min(lines.length, i + 6); j++) { + const k = lines[j].trim(); + if (k.startsWith('sk-')) return k; + } + } } - } - // fallback: первый sk-* в файле - for (const l of lines) { - const t = l.trim(); - if (t.startsWith('sk-')) return t; - } - return null; + // fallback: первый sk-* в файле + for (const l of lines) { + const t = l.trim(); + if (t.startsWith('sk-')) return t; + } + return null; } const API_KEY = findApiKey(); function log(label, obj) { - console.log(`\n=== ${label} ===`); - console.log(typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2)); + console.log(`\n=== ${label} ===`); + console.log(typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2)); } async function chat(body, { timeoutMs = 60000 } = {}) { - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), timeoutMs); - try { - const resp = await fetch(`${BASE_URL}/v1/chat/completions`, { - method: 'POST', - headers: { - Authorization: `Bearer ${API_KEY}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - signal: ctrl.signal, - }); - const text = await resp.text(); - let json = null; - try { json = JSON.parse(text); } catch { json = { _raw: text.slice(0, 500) }; } - return { status: resp.status, headers: Object.fromEntries(resp.headers), json }; - } catch (e) { - return { status: 'ERR', error: e.name + ': ' + e.message }; - } finally { - clearTimeout(timer); - } + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const resp = await fetch(`${BASE_URL}/v1/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal: ctrl.signal, + }); + const text = await resp.text(); + let json = null; + try { json = JSON.parse(text); } catch { json = { _raw: text.slice(0, 500) }; } + return { status: resp.status, headers: Object.fromEntries(resp.headers), json }; + } catch (e) { + return { status: 'ERR', error: e.name + ': ' + e.message }; + } finally { + clearTimeout(timer); + } } function t() { - const s = Date.now(); - return () => Date.now() - s; + const s = Date.now(); + return () => Date.now() - s; } async function main() { - if (!API_KEY) { - console.error('Ключ не найден в ' + KEY_FILE); - process.exit(1); - } - console.log('API_KEY: ' + API_KEY.slice(0, 6) + '*** (маска)'); - console.log('BASE_URL: ' + BASE_URL); - console.log('MODEL: ' + MODEL); + if (!API_KEY) { + console.error('Ключ не найден в ' + KEY_FILE); + process.exit(1); + } + console.log('API_KEY: ' + API_KEY.slice(0, 6) + '*** (маска)'); + console.log('BASE_URL: ' + BASE_URL); + console.log('MODEL: ' + MODEL); - // 1. response_format json_object - log('1. response_format json_object', await chat({ - model: MODEL, - messages: [{ role: 'user', content: 'Верни JSON {"resource_type":"nubes_vc_vdc"} и ничего больше.' }], - response_format: { type: 'json_object' }, - temperature: 0, - })); - - // 3. reasoning_effort low - log('3. reasoning_effort low', await chat({ - model: MODEL, - messages: [{ role: 'user', content: 'Одним словом: что такое vDC?' }], - reasoning_effort: 'low', - temperature: 0, - })); - - // 7. задержка этапа 1 (3 замера) - log('7. задержка (3 замера этап-1-подобного)', await (async () => { - const results = []; - for (let i = 0; i < 3; i++) { - const d = t(); - await chat({ + // 1. response_format json_object + log('1. response_format json_object', await chat({ model: MODEL, - messages: [ - { role: 'system', content: 'Ты ассистент документации. Ответь строгим JSON: {"resource_type":"..."}.' }, - { role: 'user', content: 'как создать виртуальный датацентр' }, - ], + messages: [{ role: 'user', content: 'Верни JSON {"resource_type":"nubes_vc_vdc"} и ничего больше.' }], + response_format: { type: 'json_object' }, temperature: 0, - }); - results.push(d()); - } - return results; - })()); + })); - // 5. размер контекста — увеличиваем промпт - log('5. контекст (пробуем ~8k и ~16k токенов)', await (async () => { - const mk = (words) => Array(words).fill('слово').join(' '); - const r = {}; - for (const words of [2000, 8000, 16000]) { - const d = t(); - const res = await chat({ + // 3. reasoning_effort low + log('3. reasoning_effort low', await chat({ model: MODEL, - messages: [{ role: 'user', content: `Повтори одно слово "ок". ${mk(words)}` }], - max_tokens: 10, - }); - r[words] = { ms: d(), status: res.status, err: res.error || (res.json && res.json.error ? res.json.error.message : null) }; - } - return r; - })()); + messages: [{ role: 'user', content: 'Одним словом: что такое vDC?' }], + reasoning_effort: 'low', + temperature: 0, + })); - // 6. пустой content (маленький max_tokens + reasoning) - log('6. маленький max_tokens (пустой content?)', await chat({ - model: MODEL, - messages: [{ role: 'user', content: 'Распиши подробно архитектуру Terraform провайдера Nubes.' }], - max_tokens: 1, - temperature: 0, - })); + // 7. задержка этапа 1 (3 замера) + log('7. задержка (3 замера этап-1-подобного)', await (async () => { + const results = []; + for (let i = 0; i < 3; i++) { + const d = t(); + await chat({ + model: MODEL, + messages: [ + { role: 'system', content: 'Ты ассистент документации. Ответь строгим JSON: {"resource_type":"..."}.' }, + { role: 'user', content: 'как создать виртуальный датацентр' }, + ], + temperature: 0, + }); + results.push(d()); + } + return results; + })()); - // 2. stream - log('2. stream:true (первые байты)', await (async () => { - try { - const resp = await fetch(`${BASE_URL}/v1/chat/completions`, { - method: 'POST', - headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: 'Скажи "привет"' }], stream: true }), - }); - const reader = resp.body.getReader(); - const dec = new TextDecoder(); - let buf = ''; - for (let i = 0; i < 3; i++) { - const { value, done } = await reader.read(); - if (done) break; - buf += dec.decode(value, { stream: true }); - } - return { status: resp.status, firstChunks: buf.slice(0, 800) }; - } catch (e) { - return { error: e.name + ': ' + e.message }; - } - })()); + // 5. размер контекста — увеличиваем промпт + log('5. контекст (пробуем ~8k и ~16k токенов)', await (async () => { + const mk = (words) => Array(words).fill('слово').join(' '); + const r = {}; + for (const words of [2000, 8000, 16000]) { + const d = t(); + const res = await chat({ + model: MODEL, + messages: [{ role: 'user', content: `Повтори одно слово "ок". ${mk(words)}` }], + max_tokens: 10, + }); + r[words] = { ms: d(), status: res.status, err: res.error || (res.json && res.json.error ? res.json.error.message : null) }; + } + return r; + })()); - // 4. 429 — пытаемся получить, посылая запрос с заведомо неверным (но тем же) ключом не будем; отдельно проверим /v1/models - log('4. /v1/models (проверка доступности)', await (async () => { - try { - const resp = await fetch(`${BASE_URL}/v1/models`, { - headers: { Authorization: `Bearer ${API_KEY}` }, - }); - const text = await resp.text().catch(() => ''); - let m = null; try { m = JSON.parse(text).data.map(x => x.id); } catch { m = text.slice(0, 500); } - return { status: resp.status, models: m }; - } catch (e) { - return { error: e.name + ': ' + e.message }; - } - })()); + // 6. пустой content (маленький max_tokens + reasoning) + log('6. маленький max_tokens (пустой content?)', await chat({ + model: MODEL, + messages: [{ role: 'user', content: 'Распиши подробно архитектуру Terraform провайдера Nubes.' }], + max_tokens: 1, + temperature: 0, + })); + + // 2. stream + log('2. stream:true (первые байты)', await (async () => { + try { + const resp = await fetch(`${BASE_URL}/v1/chat/completions`, { + method: 'POST', + headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: 'Скажи "привет"' }], stream: true }), + }); + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + for (let i = 0; i < 3; i++) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + } + return { status: resp.status, firstChunks: buf.slice(0, 800) }; + } catch (e) { + return { error: e.name + ': ' + e.message }; + } + })()); + + // 4. 429 — пытаемся получить, посылая запрос с заведомо неверным (но тем же) ключом не будем; отдельно проверим /v1/models + log('4. /v1/models (проверка доступности)', await (async () => { + try { + const resp = await fetch(`${BASE_URL}/v1/models`, { + headers: { Authorization: `Bearer ${API_KEY}` }, + }); + const text = await resp.text().catch(() => ''); + let m = null; try { m = JSON.parse(text).data.map(x => x.id); } catch { m = text.slice(0, 500); } + return { status: resp.status, models: m }; + } catch (e) { + return { error: e.name + ': ' + e.message }; + } + })()); } main().catch((e) => { console.error('FATAL', e); process.exit(1); }); diff --git a/orchestrator.js b/orchestrator.js index 4713bbf..3e3b545 100644 --- a/orchestrator.js +++ b/orchestrator.js @@ -37,32 +37,32 @@ const STAND_RE = /^[A-Za-z0-9._-]+$/; * @returns {Promise} - index из loadIndex() или null. */ async function fetchIndex(stand, vmDocsBaseUrl) { - try { - const url = `${vmDocsBaseUrl.replace(/\/+$/, '')}/${stand}/search/search_index.json`; - const mod = url.startsWith('https:') ? HTTPS : HTTP; - const text = await httpGet(mod, url); - return loadIndex(text); - } catch (e) { - return null; - } + try { + const url = `${vmDocsBaseUrl.replace(/\/+$/, '')}/${stand}/search/search_index.json`; + const mod = url.startsWith('https:') ? HTTPS : HTTP; + const text = await httpGet(mod, url); + return loadIndex(text); + } catch (e) { + return null; + } } /** Простой GET по http/https с возвратом тела. */ function httpGet(mod, url) { - return new Promise((resolve, reject) => { - const req = mod.get(url, (res) => { - if (res.statusCode !== 200) { - res.resume(); - reject(new Error('index status ' + res.statusCode)); - return; - } - let data = ''; - res.on('data', (c) => { data += c; }); - res.on('end', () => resolve(data)); + return new Promise((resolve, reject) => { + const req = mod.get(url, (res) => { + if (res.statusCode !== 200) { + res.resume(); + reject(new Error('index status ' + res.statusCode)); + return; + } + let data = ''; + res.on('data', (c) => { data += c; }); + res.on('end', () => resolve(data)); + }); + req.on('error', reject); + req.setTimeout(10000, () => req.destroy(new Error('index timeout'))); }); - req.on('error', reject); - req.setTimeout(10000, () => req.destroy(new Error('index timeout'))); - }); } /** @@ -76,245 +76,245 @@ function httpGet(mod, url) { * @param {object} [config.shared] - разделяемые состояния (семафор, кэш). */ async function handleAssist(req, res, config) { - const stand = config.stand; - const vmDocsBaseUrl = config.vmDocsBaseUrl; + const stand = config.stand; + const vmDocsBaseUrl = config.vmDocsBaseUrl; - // 1) Валидация входа. - const input = await validateInput(req); - if (!input.ok) { - jsonError(res, statusForCode(input.code), input.code, input.message); - return; - } - const question = input.question; - const normKey = `${stand}\u0000${normalizeQuery(question)}`; - - // 2) Rate-limit (по IP). - const ip = clientIp(req); - const limiter = config.shared.limiter; - const lim = limiter.check(ip); - if (!lim.ok) { - jsonError(res, 429, 'rate_limited', 'too many requests', { 'Retry-After': String(lim.retryAfterSec) }); - return; - } - - // 3) Кэш. - const cache = config.shared.cache; - if (cache.has(normKey)) { - sendJson(res, 200, cache.get(normKey)); - return; - } - - // 4) Дедуп: одинаковый запрос ждёт результат первого. - if (config.shared.inflight.has(normKey)) { - await waitForInflight(normKey, config.shared, res); - return; - } - - // 5) Семафор. - const sem = config.shared.semaphore; - if (!sem.tryAcquire()) { - jsonError(res, 503, 'busy', 'server is busy, retry later', { 'Retry-After': '5' }); - return; - } - - // Регистрируем как «держащий вызов». - const inflightEntry = { holderCount: 1, waiters: [] }; - config.shared.inflight.set(normKey, inflightEntry); - - const controller = new AbortController(); - const start = Date.now(); - let holderActive = true; - - // Прерывание LLM при закрытии соединения клиентом. - const onClose = () => { - inflightEntry.holderCount -= 1; - if (inflightEntry.holderCount <= 0 && holderActive) { - controller.abort(); + // 1) Валидация входа. + const input = await validateInput(req); + if (!input.ok) { + jsonError(res, statusForCode(input.code), input.code, input.message); + return; } - }; - req.on('close', onClose); + const question = input.question; + const normKey = `${stand}\u0000${normalizeQuery(question)}`; - const release = () => { - try { sem.release(); } catch {} - req.removeListener('close', onClose); - config.shared.inflight.delete(normKey); - // пробуждаем ожидающих результатом (результат упадёт — отдаём 502/503 им). - for (const w of inflightEntry.waiters) w.res(res); - }; + // 2) Rate-limit (по IP). + const ip = clientIp(req); + const limiter = config.shared.limiter; + const lim = limiter.check(ip); + if (!lim.ok) { + jsonError(res, 429, 'rate_limited', 'too many requests', { 'Retry-After': String(lim.retryAfterSec) }); + return; + } - try { - const result = await runPipeline(question, stand, vmDocsBaseUrl, controller, start); - holderActive = false; - const body = { answer: result.answer, sources: result.sources, meta: result.meta }; - cache.set(normKey, body); - sendJson(res, 200, body); - for (const w of inflightEntry.waiters) w.succeed(body); - } catch (err) { - holderActive = false; - const code = err && err.code ? err.code : 'internal_error'; - const status = mapPipelineError(code); - for (const w of inflightEntry.waiters) w.fail(status, code); - jsonError(res, status, code, err && err.message ? err.message : 'internal error'); - } finally { - release(); - } + // 3) Кэш. + const cache = config.shared.cache; + if (cache.has(normKey)) { + sendJson(res, 200, cache.get(normKey)); + return; + } + + // 4) Дедуп: одинаковый запрос ждёт результат первого. + if (config.shared.inflight.has(normKey)) { + await waitForInflight(normKey, config.shared, res); + return; + } + + // 5) Семафор. + const sem = config.shared.semaphore; + if (!sem.tryAcquire()) { + jsonError(res, 503, 'busy', 'server is busy, retry later', { 'Retry-After': '5' }); + return; + } + + // Регистрируем как «держащий вызов». + const inflightEntry = { holderCount: 1, waiters: [] }; + config.shared.inflight.set(normKey, inflightEntry); + + const controller = new AbortController(); + const start = Date.now(); + let holderActive = true; + + // Прерывание LLM при закрытии соединения клиентом. + const onClose = () => { + inflightEntry.holderCount -= 1; + if (inflightEntry.holderCount <= 0 && holderActive) { + controller.abort(); + } + }; + req.on('close', onClose); + + const release = () => { + try { sem.release(); } catch { } + req.removeListener('close', onClose); + config.shared.inflight.delete(normKey); + // пробуждаем ожидающих результатом (результат упадёт — отдаём 502/503 им). + for (const w of inflightEntry.waiters) w.res(res); + }; + + try { + const result = await runPipeline(question, stand, vmDocsBaseUrl, controller, start); + holderActive = false; + const body = { answer: result.answer, sources: result.sources, meta: result.meta }; + cache.set(normKey, body); + sendJson(res, 200, body); + for (const w of inflightEntry.waiters) w.succeed(body); + } catch (err) { + holderActive = false; + const code = err && err.code ? err.code : 'internal_error'; + const status = mapPipelineError(code); + for (const w of inflightEntry.waiters) w.fail(status, code); + jsonError(res, status, code, err && err.message ? err.message : 'internal error'); + } finally { + release(); + } } /** Возвращает IP клиента (7.3: X-Forwarded-For только для доверенных proxy). */ function clientIp(req) { - const trusted = (ENV.ASSIST_TRUSTED_PROXY_CIDR || '').trim(); - const remote = req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : ''; - if (trusted && remoteIsInCidr(remote, trusted)) { - const xff = req.headers['x-forwarded-for']; - if (xff) { - const parts = String(xff).split(',').map((s) => s.trim()).filter(Boolean); - if (parts.length) return parts[parts.length - 1]; + const trusted = (ENV.ASSIST_TRUSTED_PROXY_CIDR || '').trim(); + const remote = req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : ''; + if (trusted && remoteIsInCidr(remote, trusted)) { + const xff = req.headers['x-forwarded-for']; + if (xff) { + const parts = String(xff).split(',').map((s) => s.trim()).filter(Boolean); + if (parts.length) return parts[parts.length - 1]; + } } - } - return remote || 'unknown'; + return remote || 'unknown'; } /** Простейшая проверка IP == CIDR (точное совпадение или префикс). v1: только равенство/CIDR /32. */ function remoteIsInCidr(ip, cidr) { - // Нормализация IPv6-префикса ::ffff:1.2.3.4 → 1.2.3.4 - const norm = String(ip).replace(/^::ffff:/, ''); - const parts = cidr.split(','); - for (const p of parts) { - const t = p.trim(); - if (!t) continue; - if (!t.includes('/')) { - if (t === norm) return true; - continue; + // Нормализация IPv6-префикса ::ffff:1.2.3.4 → 1.2.3.4 + const norm = String(ip).replace(/^::ffff:/, ''); + const parts = cidr.split(','); + for (const p of parts) { + const t = p.trim(); + if (!t) continue; + if (!t.includes('/')) { + if (t === norm) return true; + continue; + } + const [base, bitsStr] = t.split('/'); + if (t === norm) return true; + const bits = Number.parseInt(bitsStr, 10); + if (Number.isFinite(bits) && bits >= 32 && base === norm) return true; } - const [base, bitsStr] = t.split('/'); - if (t === norm) return true; - const bits = Number.parseInt(bitsStr, 10); - if (Number.isFinite(bits) && bits >= 32 && base === norm) return true; - } - return false; + return false; } /** Основной пайплайн (этап 1 → поиск → объединение → этап 2). */ async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) { - const t1 = Date.now(); - const index = await fetchIndex(stand, vmDocsBaseUrl); - if (!index) { - throw { code: 'index_unavailable', message: 'search index unavailable' }; - } - const resourceTypes = index.listResourceTypes(); + const t1 = Date.now(); + const index = await fetchIndex(stand, vmDocsBaseUrl); + if (!index) { + throw { code: 'index_unavailable', message: 'search index unavailable' }; + } + const resourceTypes = index.listResourceTypes(); - // Этап 1 (resource_type). - let resourceType = null; - let stage1Ok = false; - const budget = TOTAL_DEADLINE_MS - (Date.now() - start); - if (budget > 0 && resourceTypes.size > 0) { - const r1 = await stage1ExtractResourceType(question, { - resourceTypesSet: resourceTypes, - controller, + // Этап 1 (resource_type). + let resourceType = null; + let stage1Ok = false; + const budget = TOTAL_DEADLINE_MS - (Date.now() - start); + if (budget > 0 && resourceTypes.size > 0) { + const r1 = await stage1ExtractResourceType(question, { + resourceTypesSet: resourceTypes, + controller, + }); + stage1Ok = r1.ok; + if (r1.ok && r1.resourceType) resourceType = r1.resourceType; + } + + // Поиск по токенам исходного запроса — всегда (параллельно уже можно, но для простоты + // объединяем в mergeSearchResults, который сам делает поиск по query и по resource_type). + const merged = mergeSearchResults(index, question, resourceType, { topK: 5 }); + const context = buildContext(merged, 1500); + + // Этап 2. + const r2 = await stage2Generate({ + question, + context, + baseUrl: ENV.LLM_BASE_URL, + apiKey: ENV.LLM_API_KEY, + model: ENV.LLM_MODEL, + maxTokens: 1024, + controller, }); - stage1Ok = r1.ok; - if (r1.ok && r1.resourceType) resourceType = r1.resourceType; - } - // Поиск по токенам исходного запроса — всегда (параллельно уже можно, но для простоты - // объединяем в mergeSearchResults, который сам делает поиск по query и по resource_type). - const merged = mergeSearchResults(index, question, resourceType, { topK: 5 }); - const context = buildContext(merged, 1500); + if (!r2.ok) { + throw { code: r2.error, message: r2.error === 'bad_llm_output' ? 'bad llm output' : 'llm error' }; + } - // Этап 2. - const r2 = await stage2Generate({ - question, - context, - baseUrl: ENV.LLM_BASE_URL, - apiKey: ENV.LLM_API_KEY, - model: ENV.LLM_MODEL, - maxTokens: 1024, - controller, - }); + const sources = collectSources(merged, stand); - if (!r2.ok) { - throw { code: r2.error, message: r2.error === 'bad_llm_output' ? 'bad llm output' : 'llm error' }; - } - - const sources = collectSources(merged, stand); - - return { - answer: r2.answer, - sources, - meta: { - model: ENV.LLM_MODEL, - resource_type: resourceType, - stage1_ok: stage1Ok, - elapsed_ms: Date.now() - t1, - }, - }; + return { + answer: r2.answer, + sources, + meta: { + model: ENV.LLM_MODEL, + resource_type: resourceType, + stage1_ok: stage1Ok, + elapsed_ms: Date.now() - t1, + }, + }; } /** Централизованный семафор/кэш/inflight-состояния (инициализируется один раз). */ function createSharedState() { - return { - semaphore: createSemaphore(ENV.ASSIST_MAX_CONCURRENT), - limiter: createRateLimiter({}), - cache: new Map(), - inflight: new Map(), - }; + return { + semaphore: createSemaphore(ENV.ASSIST_MAX_CONCURRENT), + limiter: createRateLimiter({}), + cache: new Map(), + inflight: new Map(), + }; } /** Ожидание результата уже выполняющегося одинакового запроса. */ function waitForInflight(normKey, shared, res) { - return new Promise((resolvePromise) => { - const entry = shared.inflight.get(normKey); - if (!entry) { - // запрос завершился между проверками — просто повторим обработку - return handleAssistAgain(res); // fallback (не используется в v1) - } - entry.waiters.push({ - succeed(body) { sendJson(res, 200, body); resolvePromise(); }, - fail(status, code) { jsonError(res, status, code, 'upstream failed'); resolvePromise(); }, - res() {}, + return new Promise((resolvePromise) => { + const entry = shared.inflight.get(normKey); + if (!entry) { + // запрос завершился между проверками — просто повторим обработку + return handleAssistAgain(res); // fallback (не используется в v1) + } + entry.waiters.push({ + succeed(body) { sendJson(res, 200, body); resolvePromise(); }, + fail(status, code) { jsonError(res, status, code, 'upstream failed'); resolvePromise(); }, + res() { }, + }); }); - }); } /** Заглушка fallback (не используется). */ -function handleAssistAgain() {} +function handleAssistAgain() { } function statusForCode(code) { - switch (code) { - case 400: return 400; - case 413: return 413; - case 415: return 415; - default: return 400; - } + switch (code) { + case 400: return 400; + case 413: return 413; + case 415: return 415; + default: return 400; + } } function mapPipelineError(code) { - switch (code) { - case 'bad_llm_output': return 502; - case 'index_unavailable': return 503; - case 'empty_content': return 502; - case 'aborted': return 499; - case 'rate_limited': return 503; - default: return 502; - } + switch (code) { + case 'bad_llm_output': return 502; + case 'index_unavailable': return 503; + case 'empty_content': return 502; + case 'aborted': return 499; + case 'rate_limited': return 503; + default: return 502; + } } function sendJson(res, status, obj) { - const body = JSON.stringify(obj); - if (!res.headersSent) { - res.writeHead(status, { - 'Content-Type': 'application/json; charset=utf-8', - 'Content-Length': Buffer.byteLength(body), - }); - } - res.end(body); + const body = JSON.stringify(obj); + if (!res.headersSent) { + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }); + } + res.end(body); } module.exports = { - handleAssist, - createSharedState, - runPipeline, - fetchIndex, - clientIp, - STAND_RE, + handleAssist, + createSharedState, + runPipeline, + fetchIndex, + clientIp, + STAND_RE, }; diff --git a/server.js b/server.js index bb50cef..d5e4a1f 100644 --- a/server.js +++ b/server.js @@ -97,7 +97,7 @@ const server = http.createServer((req, res) => { res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ error: 'internal_error', message: 'assist failed' })); } - } catch (_) {} + } catch (_) { } }); return; } diff --git a/stage1.js b/stage1.js index 66299e9..7e379d4 100644 --- a/stage1.js +++ b/stage1.js @@ -16,12 +16,12 @@ const { llmChat } = require('./assist.js'); const SYSTEM_PROMPT = - 'Ты — помощник по документации Terraform-провайдера Nubes. ' + - 'Определи, к какому типу ресурса относится вопрос пользователя. ' + - 'Отвечай строго JSON вида {"resource_type":"nubes_<имя>"}. ' + - 'Используй ТОЛЬКО типы из списка ниже. ' + - 'Если вопрос не относится ни к одному типу или тип не из списка — верни {"resource_type":""}. ' + - 'Не выдумывай типы.'; + 'Ты — помощник по документации Terraform-провайдера Nubes. ' + + 'Определи, к какому типу ресурса относится вопрос пользователя. ' + + 'Отвечай строго JSON вида {"resource_type":"nubes_<имя>"}. ' + + 'Используй ТОЛЬКО типы из списка ниже. ' + + 'Если вопрос не относится ни к одному типу или тип не из списка — верни {"resource_type":""}. ' + + 'Не выдумывай типы.'; const DEFAULT_MAX_TOKENS = 256; const DEFAULT_MODEL = 'gpt-oss-120b'; @@ -36,19 +36,19 @@ const DEFAULT_MODEL = 'gpt-oss-120b'; * @returns {Array<{role:string, content:string}>} */ function buildStage1Messages(question, resourceTypesSet) { - const types = [...new Set(resourceTypesSet || [])].map((t) => String(t)).sort(); - const list = types.length > 0 ? types.join('\n') : '(пусто)'; + const types = [...new Set(resourceTypesSet || [])].map((t) => String(t)).sort(); + const list = types.length > 0 ? types.join('\n') : '(пусто)'; - const userContent = - 'Доступные типы ресурсов (используй ТОЛЬКО из этого списка):\n' + - list + - '\n\nВопрос пользователя:\n' + - String(question ?? ''); + const userContent = + 'Доступные типы ресурсов (используй ТОЛЬКО из этого списка):\n' + + list + + '\n\nВопрос пользователя:\n' + + String(question ?? ''); - return [ - { role: 'system', content: SYSTEM_PROMPT }, - { role: 'user', content: userContent }, - ]; + return [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: userContent }, + ]; } /** @@ -59,12 +59,12 @@ function buildStage1Messages(question, resourceTypesSet) { * @returns {string} */ function extractJsonString(raw) { - const text = String(raw ?? '').trim(); - const stripped = text.replace(/```(?:json)?\s*/gi, '').replace(/```/g, ''); - const start = stripped.indexOf('{'); - const end = stripped.lastIndexOf('}'); - if (start === -1 || end === -1 || end < start) return ''; - return stripped.slice(start, end + 1); + const text = String(raw ?? '').trim(); + const stripped = text.replace(/```(?:json)?\s*/gi, '').replace(/```/g, ''); + const start = stripped.indexOf('{'); + const end = stripped.lastIndexOf('}'); + if (start === -1 || end === -1 || end < start) return ''; + return stripped.slice(start, end + 1); } /** @@ -75,18 +75,18 @@ function extractJsonString(raw) { * @returns {string} */ function parseResourceType(content) { - const jsonStr = extractJsonString(content); - if (!jsonStr) return ''; - let obj; - try { - obj = JSON.parse(jsonStr); - } catch { - return ''; - } - if (!obj || typeof obj !== 'object') return ''; - const raw = obj.resource_type; - if (typeof raw !== 'string') return ''; - return raw.toLowerCase().trim(); + const jsonStr = extractJsonString(content); + if (!jsonStr) return ''; + let obj; + try { + obj = JSON.parse(jsonStr); + } catch { + return ''; + } + if (!obj || typeof obj !== 'object') return ''; + const raw = obj.resource_type; + if (typeof raw !== 'string') return ''; + return raw.toLowerCase().trim(); } /** @@ -112,80 +112,80 @@ function parseResourceType(content) { * @returns {Promise<{ok:boolean, resourceType?:string|null, error?:string}>} */ async function stage1ExtractResourceType(question, { - resourceTypesSet, - baseUrl = process.env.LLM_BASE_URL || '', - apiKey = process.env.LLM_API_KEY || '', - model = process.env.LLM_MODEL || DEFAULT_MODEL, - timeoutMs, - controller, + resourceTypesSet, + baseUrl = process.env.LLM_BASE_URL || '', + apiKey = process.env.LLM_API_KEY || '', + model = process.env.LLM_MODEL || DEFAULT_MODEL, + timeoutMs, + controller, }) { - const types = new Set( - [...new Set(resourceTypesSet || [])].map((t) => String(t).toLowerCase().trim()) - ); + const types = new Set( + [...new Set(resourceTypesSet || [])].map((t) => String(t).toLowerCase().trim()) + ); - if (types.size === 0) { - return { ok: false, error: 'no_resource_types' }; - } + if (types.size === 0) { + return { ok: false, error: 'no_resource_types' }; + } - const messages = buildStage1Messages(question, types); + const messages = buildStage1Messages(question, types); - const attempt = async (maxTokens) => { - const params = { - messages, - maxTokens, - temperature: 0, - responseFormat: { type: 'json_object' }, - controller, - baseUrl, - apiKey, - model, + const attempt = async (maxTokens) => { + const params = { + messages, + maxTokens, + temperature: 0, + responseFormat: { type: 'json_object' }, + controller, + baseUrl, + apiKey, + model, + }; + let timer = null; + let ctrl = controller; + if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) { + ctrl = new AbortController(); + timer = setTimeout(() => ctrl.abort(), timeoutMs); + const forward = () => ctrl.abort(); + if (controller) { + if (controller.signal.aborted) ctrl.abort(); + else controller.signal.addEventListener('abort', forward, { once: true }); + } + params.controller = ctrl; + try { + return await llmChat(params); + } finally { + clearTimeout(timer); + if (controller) controller.signal.removeEventListener('abort', forward); + } + } + return llmChat(params); }; - let timer = null; - let ctrl = controller; - if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) { - ctrl = new AbortController(); - timer = setTimeout(() => ctrl.abort(), timeoutMs); - const forward = () => ctrl.abort(); - if (controller) { - if (controller.signal.aborted) ctrl.abort(); - else controller.signal.addEventListener('abort', forward, { once: true }); - } - params.controller = ctrl; - try { - return await llmChat(params); - } finally { - clearTimeout(timer); - if (controller) controller.signal.removeEventListener('abort', forward); - } + + let content; + try { + content = await attempt(DEFAULT_MAX_TOKENS); + } catch (err) { + if (err && err.code === 'empty_content') { + try { + content = await attempt(DEFAULT_MAX_TOKENS * 2); + } catch (err2) { + return { ok: false, error: err2 && err2.code ? err2.code : 'stage1_retry_failed' }; + } + } else { + return { ok: false, error: err && err.code ? err.code : 'stage1_failed' }; + } } - return llmChat(params); - }; - let content; - try { - content = await attempt(DEFAULT_MAX_TOKENS); - } catch (err) { - if (err && err.code === 'empty_content') { - try { - content = await attempt(DEFAULT_MAX_TOKENS * 2); - } catch (err2) { - return { ok: false, error: err2 && err2.code ? err2.code : 'stage1_retry_failed' }; - } - } else { - return { ok: false, error: err && err.code ? err.code : 'stage1_failed' }; + const resourceType = parseResourceType(content); + + if (resourceType && types.has(resourceType)) { + return { ok: true, resourceType }; } - } - const resourceType = parseResourceType(content); - - if (resourceType && types.has(resourceType)) { - return { ok: true, resourceType }; - } - - return { ok: true, resourceType: null }; + return { ok: true, resourceType: null }; } module.exports = { - stage1ExtractResourceType, - buildStage1Messages, + stage1ExtractResourceType, + buildStage1Messages, };