chore: форматирование модулей + план по шагам
This commit is contained in:
@@ -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 (путь `/<stand>/_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/<stand>/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` = `/<stand>/` + `location`; отбрасывать `//`, `:`, `..`.
|
||||||
|
**Критерий готово:** полный сквозной ответ на эталоне, корректные `sources`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 7. Маршрут в `server.js` + Dockerfile + README
|
||||||
|
|
||||||
|
**Задача Flash (под контролем):** в `server.js` добавить POST-ветку `/<stand>/_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`; нет сегмента — не показывать.
|
||||||
|
- Плавающая кнопка на всех страницах.
|
||||||
|
- Под полем ввода: «не вводите ключи и пароли».
|
||||||
|
- Обращение к `/<stand>/_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).
|
||||||
@@ -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": "Как создать виртуальную машину в облаке Nubes?",
|
||||||
{"question": "Как создать базу данных и пользователя в PostgreSQL?", "expected_pages": ["postgres"], "resource_type": "nubes_postgres_database"},
|
"expected_pages": [
|
||||||
{"question": "Как выдать виртуальной машине белый IP-адрес?", "expected_pages": ["vcexternalip"], "resource_type": "nubes_vcexternalip"},
|
"vc_vm_v3"
|
||||||
{"question": "Как поднять MongoDB с ежедневным бэкапом?", "expected_pages": ["mongodb"], "resource_type": "nubes_mongodb"},
|
],
|
||||||
{"question": "Как завести пользователя и пароль для MongoDB?", "expected_pages": ["mongodb"], "resource_type": "nubes_mongodb_user"},
|
"resource_type": "nubes_vc_vm_v3"
|
||||||
{"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": "Как настроить PostgreSQL с автоматическим бэкапом?",
|
||||||
{"question": "Как развернуть Kafka-кластер и создать топик?", "expected_pages": ["kafka"], "resource_type": "nubes_kafka_topic"},
|
"expected_pages": [
|
||||||
{"question": "Как завести пользователя Kafka с правами на конкретный топик?", "expected_pages": ["kafka"], "resource_type": "nubes_kafka_user"},
|
"postgres"
|
||||||
{"question": "Как развернуть RabbitMQ для очередей задач?", "expected_pages": ["rabbitmq"], "resource_type": "nubes_rabbitmq"},
|
],
|
||||||
{"question": "Как создать S3-бакет для хранения файлов?", "expected_pages": ["s3bucket", "s3"], "resource_type": "nubes_s3bucket"},
|
"resource_type": "nubes_postgres"
|
||||||
{"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": "Как создать базу данных и пользователя в PostgreSQL?",
|
||||||
{"question": "Как развернуть OpenBao для хранения секретов в кластере?", "expected_pages": ["k8s_openbao"], "resource_type": "nubes_k8s_openbao"},
|
"expected_pages": [
|
||||||
{"question": "Как создать DNS-запись для своего домена?", "expected_pages": ["dnsrecord", "zones_v2"], "resource_type": "nubes_dnsrecord"},
|
"postgres"
|
||||||
{"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"},
|
"resource_type": "nubes_postgres_database"
|
||||||
{"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": "Как выдать виртуальной машине белый IP-адрес?",
|
||||||
{"question": "Как задеплоить Flask-приложение и получить его URL?", "expected_pages": ["flask"], "resource_type": "nubes_flask"},
|
"expected_pages": [
|
||||||
{"question": "Как развернуть pgAdmin для управления базами?", "expected_pages": ["pgadmin"], "resource_type": "nubes_pgadmin"},
|
"vcexternalip"
|
||||||
{"question": "Как подключиться к API Terraform-провайдера Nubes?", "expected_pages": [], "resource_type": ""},
|
],
|
||||||
{"question": "Какие версии провайдера сейчас доступны и как их обновлять?", "expected_pages": ["template"], "resource_type": ""},
|
"resource_type": "nubes_vcexternalip"
|
||||||
{"question": "Как создать nubes_instance с плавающим IP floating_ip?", "expected_pages": [], "resource_type": ""}
|
},
|
||||||
]
|
{
|
||||||
|
"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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
+134
-134
@@ -24,163 +24,163 @@ const MODEL = process.env.LLM_MODEL || 'gpt-oss-120b';
|
|||||||
|
|
||||||
// ── поиск ключа: первая строка, начинающаяся с sk-, после строки, содержащей 'https' ──
|
// ── поиск ключа: первая строка, начинающаяся с sk-, после строки, содержащей 'https' ──
|
||||||
function findApiKey() {
|
function findApiKey() {
|
||||||
const lines = fs.readFileSync(KEY_FILE, 'utf8').split(/\r?\n/);
|
const lines = fs.readFileSync(KEY_FILE, 'utf8').split(/\r?\n/);
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const t = lines[i].trim();
|
const t = lines[i].trim();
|
||||||
if (t.toLowerCase().includes('https') || t.toLowerCase().includes('aillm')) {
|
if (t.toLowerCase().includes('https') || t.toLowerCase().includes('aillm')) {
|
||||||
// дальше ищем ближайший sk-... в следующих 5 строках
|
// дальше ищем ближайший sk-... в следующих 5 строках
|
||||||
for (let j = i + 1; j < Math.min(lines.length, i + 6); j++) {
|
for (let j = i + 1; j < Math.min(lines.length, i + 6); j++) {
|
||||||
const k = lines[j].trim();
|
const k = lines[j].trim();
|
||||||
if (k.startsWith('sk-')) return k;
|
if (k.startsWith('sk-')) return k;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
// fallback: первый sk-* в файле
|
||||||
// fallback: первый sk-* в файле
|
for (const l of lines) {
|
||||||
for (const l of lines) {
|
const t = l.trim();
|
||||||
const t = l.trim();
|
if (t.startsWith('sk-')) return t;
|
||||||
if (t.startsWith('sk-')) return t;
|
}
|
||||||
}
|
return null;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_KEY = findApiKey();
|
const API_KEY = findApiKey();
|
||||||
|
|
||||||
function log(label, obj) {
|
function log(label, obj) {
|
||||||
console.log(`\n=== ${label} ===`);
|
console.log(`\n=== ${label} ===`);
|
||||||
console.log(typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2));
|
console.log(typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chat(body, { timeoutMs = 60000 } = {}) {
|
async function chat(body, { timeoutMs = 60000 } = {}) {
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${BASE_URL}/v1/chat/completions`, {
|
const resp = await fetch(`${BASE_URL}/v1/chat/completions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${API_KEY}`,
|
Authorization: `Bearer ${API_KEY}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
});
|
});
|
||||||
const text = await resp.text();
|
const text = await resp.text();
|
||||||
let json = null;
|
let json = null;
|
||||||
try { json = JSON.parse(text); } catch { json = { _raw: text.slice(0, 500) }; }
|
try { json = JSON.parse(text); } catch { json = { _raw: text.slice(0, 500) }; }
|
||||||
return { status: resp.status, headers: Object.fromEntries(resp.headers), json };
|
return { status: resp.status, headers: Object.fromEntries(resp.headers), json };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { status: 'ERR', error: e.name + ': ' + e.message };
|
return { status: 'ERR', error: e.name + ': ' + e.message };
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function t() {
|
function t() {
|
||||||
const s = Date.now();
|
const s = Date.now();
|
||||||
return () => Date.now() - s;
|
return () => Date.now() - s;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
if (!API_KEY) {
|
if (!API_KEY) {
|
||||||
console.error('Ключ не найден в ' + KEY_FILE);
|
console.error('Ключ не найден в ' + KEY_FILE);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log('API_KEY: ' + API_KEY.slice(0, 6) + '*** (маска)');
|
console.log('API_KEY: ' + API_KEY.slice(0, 6) + '*** (маска)');
|
||||||
console.log('BASE_URL: ' + BASE_URL);
|
console.log('BASE_URL: ' + BASE_URL);
|
||||||
console.log('MODEL: ' + MODEL);
|
console.log('MODEL: ' + MODEL);
|
||||||
|
|
||||||
// 1. response_format json_object
|
// 1. response_format json_object
|
||||||
log('1. response_format json_object', await chat({
|
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({
|
|
||||||
model: MODEL,
|
model: MODEL,
|
||||||
messages: [
|
messages: [{ role: 'user', content: 'Верни JSON {"resource_type":"nubes_vc_vdc"} и ничего больше.' }],
|
||||||
{ role: 'system', content: 'Ты ассистент документации. Ответь строгим JSON: {"resource_type":"..."}.' },
|
response_format: { type: 'json_object' },
|
||||||
{ role: 'user', content: 'как создать виртуальный датацентр' },
|
|
||||||
],
|
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
});
|
}));
|
||||||
results.push(d());
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
})());
|
|
||||||
|
|
||||||
// 5. размер контекста — увеличиваем промпт
|
// 3. reasoning_effort low
|
||||||
log('5. контекст (пробуем ~8k и ~16k токенов)', await (async () => {
|
log('3. reasoning_effort low', await chat({
|
||||||
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,
|
model: MODEL,
|
||||||
messages: [{ role: 'user', content: `Повтори одно слово "ок". ${mk(words)}` }],
|
messages: [{ role: 'user', content: 'Одним словом: что такое vDC?' }],
|
||||||
max_tokens: 10,
|
reasoning_effort: 'low',
|
||||||
});
|
temperature: 0,
|
||||||
r[words] = { ms: d(), status: res.status, err: res.error || (res.json && res.json.error ? res.json.error.message : null) };
|
}));
|
||||||
}
|
|
||||||
return r;
|
|
||||||
})());
|
|
||||||
|
|
||||||
// 6. пустой content (маленький max_tokens + reasoning)
|
// 7. задержка этапа 1 (3 замера)
|
||||||
log('6. маленький max_tokens (пустой content?)', await chat({
|
log('7. задержка (3 замера этап-1-подобного)', await (async () => {
|
||||||
model: MODEL,
|
const results = [];
|
||||||
messages: [{ role: 'user', content: 'Распиши подробно архитектуру Terraform провайдера Nubes.' }],
|
for (let i = 0; i < 3; i++) {
|
||||||
max_tokens: 1,
|
const d = t();
|
||||||
temperature: 0,
|
await chat({
|
||||||
}));
|
model: MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: 'Ты ассистент документации. Ответь строгим JSON: {"resource_type":"..."}.' },
|
||||||
|
{ role: 'user', content: 'как создать виртуальный датацентр' },
|
||||||
|
],
|
||||||
|
temperature: 0,
|
||||||
|
});
|
||||||
|
results.push(d());
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
})());
|
||||||
|
|
||||||
// 2. stream
|
// 5. размер контекста — увеличиваем промпт
|
||||||
log('2. stream:true (первые байты)', await (async () => {
|
log('5. контекст (пробуем ~8k и ~16k токенов)', await (async () => {
|
||||||
try {
|
const mk = (words) => Array(words).fill('слово').join(' ');
|
||||||
const resp = await fetch(`${BASE_URL}/v1/chat/completions`, {
|
const r = {};
|
||||||
method: 'POST',
|
for (const words of [2000, 8000, 16000]) {
|
||||||
headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
|
const d = t();
|
||||||
body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: 'Скажи "привет"' }], stream: true }),
|
const res = await chat({
|
||||||
});
|
model: MODEL,
|
||||||
const reader = resp.body.getReader();
|
messages: [{ role: 'user', content: `Повтори одно слово "ок". ${mk(words)}` }],
|
||||||
const dec = new TextDecoder();
|
max_tokens: 10,
|
||||||
let buf = '';
|
});
|
||||||
for (let i = 0; i < 3; i++) {
|
r[words] = { ms: d(), status: res.status, err: res.error || (res.json && res.json.error ? res.json.error.message : null) };
|
||||||
const { value, done } = await reader.read();
|
}
|
||||||
if (done) break;
|
return r;
|
||||||
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
|
// 6. пустой content (маленький max_tokens + reasoning)
|
||||||
log('4. /v1/models (проверка доступности)', await (async () => {
|
log('6. маленький max_tokens (пустой content?)', await chat({
|
||||||
try {
|
model: MODEL,
|
||||||
const resp = await fetch(`${BASE_URL}/v1/models`, {
|
messages: [{ role: 'user', content: 'Распиши подробно архитектуру Terraform провайдера Nubes.' }],
|
||||||
headers: { Authorization: `Bearer ${API_KEY}` },
|
max_tokens: 1,
|
||||||
});
|
temperature: 0,
|
||||||
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 };
|
// 2. stream
|
||||||
} catch (e) {
|
log('2. stream:true (первые байты)', await (async () => {
|
||||||
return { error: e.name + ': ' + e.message };
|
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); });
|
main().catch((e) => { console.error('FATAL', e); process.exit(1); });
|
||||||
|
|||||||
+216
-216
@@ -37,32 +37,32 @@ const STAND_RE = /^[A-Za-z0-9._-]+$/;
|
|||||||
* @returns {Promise<object|null>} - index из loadIndex() или null.
|
* @returns {Promise<object|null>} - index из loadIndex() или null.
|
||||||
*/
|
*/
|
||||||
async function fetchIndex(stand, vmDocsBaseUrl) {
|
async function fetchIndex(stand, vmDocsBaseUrl) {
|
||||||
try {
|
try {
|
||||||
const url = `${vmDocsBaseUrl.replace(/\/+$/, '')}/${stand}/search/search_index.json`;
|
const url = `${vmDocsBaseUrl.replace(/\/+$/, '')}/${stand}/search/search_index.json`;
|
||||||
const mod = url.startsWith('https:') ? HTTPS : HTTP;
|
const mod = url.startsWith('https:') ? HTTPS : HTTP;
|
||||||
const text = await httpGet(mod, url);
|
const text = await httpGet(mod, url);
|
||||||
return loadIndex(text);
|
return loadIndex(text);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Простой GET по http/https с возвратом тела. */
|
/** Простой GET по http/https с возвратом тела. */
|
||||||
function httpGet(mod, url) {
|
function httpGet(mod, url) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = mod.get(url, (res) => {
|
const req = mod.get(url, (res) => {
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
res.resume();
|
res.resume();
|
||||||
reject(new Error('index status ' + res.statusCode));
|
reject(new Error('index status ' + res.statusCode));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let data = '';
|
let data = '';
|
||||||
res.on('data', (c) => { data += c; });
|
res.on('data', (c) => { data += c; });
|
||||||
res.on('end', () => resolve(data));
|
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] - разделяемые состояния (семафор, кэш).
|
* @param {object} [config.shared] - разделяемые состояния (семафор, кэш).
|
||||||
*/
|
*/
|
||||||
async function handleAssist(req, res, config) {
|
async function handleAssist(req, res, config) {
|
||||||
const stand = config.stand;
|
const stand = config.stand;
|
||||||
const vmDocsBaseUrl = config.vmDocsBaseUrl;
|
const vmDocsBaseUrl = config.vmDocsBaseUrl;
|
||||||
|
|
||||||
// 1) Валидация входа.
|
// 1) Валидация входа.
|
||||||
const input = await validateInput(req);
|
const input = await validateInput(req);
|
||||||
if (!input.ok) {
|
if (!input.ok) {
|
||||||
jsonError(res, statusForCode(input.code), input.code, input.message);
|
jsonError(res, statusForCode(input.code), input.code, input.message);
|
||||||
return;
|
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();
|
|
||||||
}
|
}
|
||||||
};
|
const question = input.question;
|
||||||
req.on('close', onClose);
|
const normKey = `${stand}\u0000${normalizeQuery(question)}`;
|
||||||
|
|
||||||
const release = () => {
|
// 2) Rate-limit (по IP).
|
||||||
try { sem.release(); } catch {}
|
const ip = clientIp(req);
|
||||||
req.removeListener('close', onClose);
|
const limiter = config.shared.limiter;
|
||||||
config.shared.inflight.delete(normKey);
|
const lim = limiter.check(ip);
|
||||||
// пробуждаем ожидающих результатом (результат упадёт — отдаём 502/503 им).
|
if (!lim.ok) {
|
||||||
for (const w of inflightEntry.waiters) w.res(res);
|
jsonError(res, 429, 'rate_limited', 'too many requests', { 'Retry-After': String(lim.retryAfterSec) });
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
// 3) Кэш.
|
||||||
const result = await runPipeline(question, stand, vmDocsBaseUrl, controller, start);
|
const cache = config.shared.cache;
|
||||||
holderActive = false;
|
if (cache.has(normKey)) {
|
||||||
const body = { answer: result.answer, sources: result.sources, meta: result.meta };
|
sendJson(res, 200, cache.get(normKey));
|
||||||
cache.set(normKey, body);
|
return;
|
||||||
sendJson(res, 200, body);
|
}
|
||||||
for (const w of inflightEntry.waiters) w.succeed(body);
|
|
||||||
} catch (err) {
|
// 4) Дедуп: одинаковый запрос ждёт результат первого.
|
||||||
holderActive = false;
|
if (config.shared.inflight.has(normKey)) {
|
||||||
const code = err && err.code ? err.code : 'internal_error';
|
await waitForInflight(normKey, config.shared, res);
|
||||||
const status = mapPipelineError(code);
|
return;
|
||||||
for (const w of inflightEntry.waiters) w.fail(status, code);
|
}
|
||||||
jsonError(res, status, code, err && err.message ? err.message : 'internal error');
|
|
||||||
} finally {
|
// 5) Семафор.
|
||||||
release();
|
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). */
|
/** Возвращает IP клиента (7.3: X-Forwarded-For только для доверенных proxy). */
|
||||||
function clientIp(req) {
|
function clientIp(req) {
|
||||||
const trusted = (ENV.ASSIST_TRUSTED_PROXY_CIDR || '').trim();
|
const trusted = (ENV.ASSIST_TRUSTED_PROXY_CIDR || '').trim();
|
||||||
const remote = req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : '';
|
const remote = req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : '';
|
||||||
if (trusted && remoteIsInCidr(remote, trusted)) {
|
if (trusted && remoteIsInCidr(remote, trusted)) {
|
||||||
const xff = req.headers['x-forwarded-for'];
|
const xff = req.headers['x-forwarded-for'];
|
||||||
if (xff) {
|
if (xff) {
|
||||||
const parts = String(xff).split(',').map((s) => s.trim()).filter(Boolean);
|
const parts = String(xff).split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
if (parts.length) return parts[parts.length - 1];
|
if (parts.length) return parts[parts.length - 1];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return remote || 'unknown';
|
||||||
return remote || 'unknown';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Простейшая проверка IP == CIDR (точное совпадение или префикс). v1: только равенство/CIDR /32. */
|
/** Простейшая проверка IP == CIDR (точное совпадение или префикс). v1: только равенство/CIDR /32. */
|
||||||
function remoteIsInCidr(ip, cidr) {
|
function remoteIsInCidr(ip, cidr) {
|
||||||
// Нормализация IPv6-префикса ::ffff:1.2.3.4 → 1.2.3.4
|
// Нормализация IPv6-префикса ::ffff:1.2.3.4 → 1.2.3.4
|
||||||
const norm = String(ip).replace(/^::ffff:/, '');
|
const norm = String(ip).replace(/^::ffff:/, '');
|
||||||
const parts = cidr.split(',');
|
const parts = cidr.split(',');
|
||||||
for (const p of parts) {
|
for (const p of parts) {
|
||||||
const t = p.trim();
|
const t = p.trim();
|
||||||
if (!t) continue;
|
if (!t) continue;
|
||||||
if (!t.includes('/')) {
|
if (!t.includes('/')) {
|
||||||
if (t === norm) return true;
|
if (t === norm) return true;
|
||||||
continue;
|
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('/');
|
return false;
|
||||||
if (t === norm) return true;
|
|
||||||
const bits = Number.parseInt(bitsStr, 10);
|
|
||||||
if (Number.isFinite(bits) && bits >= 32 && base === norm) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Основной пайплайн (этап 1 → поиск → объединение → этап 2). */
|
/** Основной пайплайн (этап 1 → поиск → объединение → этап 2). */
|
||||||
async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) {
|
async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) {
|
||||||
const t1 = Date.now();
|
const t1 = Date.now();
|
||||||
const index = await fetchIndex(stand, vmDocsBaseUrl);
|
const index = await fetchIndex(stand, vmDocsBaseUrl);
|
||||||
if (!index) {
|
if (!index) {
|
||||||
throw { code: 'index_unavailable', message: 'search index unavailable' };
|
throw { code: 'index_unavailable', message: 'search index unavailable' };
|
||||||
}
|
}
|
||||||
const resourceTypes = index.listResourceTypes();
|
const resourceTypes = index.listResourceTypes();
|
||||||
|
|
||||||
// Этап 1 (resource_type).
|
// Этап 1 (resource_type).
|
||||||
let resourceType = null;
|
let resourceType = null;
|
||||||
let stage1Ok = false;
|
let stage1Ok = false;
|
||||||
const budget = TOTAL_DEADLINE_MS - (Date.now() - start);
|
const budget = TOTAL_DEADLINE_MS - (Date.now() - start);
|
||||||
if (budget > 0 && resourceTypes.size > 0) {
|
if (budget > 0 && resourceTypes.size > 0) {
|
||||||
const r1 = await stage1ExtractResourceType(question, {
|
const r1 = await stage1ExtractResourceType(question, {
|
||||||
resourceTypesSet: resourceTypes,
|
resourceTypesSet: resourceTypes,
|
||||||
controller,
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Поиск по токенам исходного запроса — всегда (параллельно уже можно, но для простоты
|
if (!r2.ok) {
|
||||||
// объединяем в mergeSearchResults, который сам делает поиск по query и по resource_type).
|
throw { code: r2.error, message: r2.error === 'bad_llm_output' ? 'bad llm output' : 'llm error' };
|
||||||
const merged = mergeSearchResults(index, question, resourceType, { topK: 5 });
|
}
|
||||||
const context = buildContext(merged, 1500);
|
|
||||||
|
|
||||||
// Этап 2.
|
const sources = collectSources(merged, stand);
|
||||||
const r2 = await stage2Generate({
|
|
||||||
question,
|
|
||||||
context,
|
|
||||||
baseUrl: ENV.LLM_BASE_URL,
|
|
||||||
apiKey: ENV.LLM_API_KEY,
|
|
||||||
model: ENV.LLM_MODEL,
|
|
||||||
maxTokens: 1024,
|
|
||||||
controller,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!r2.ok) {
|
return {
|
||||||
throw { code: r2.error, message: r2.error === 'bad_llm_output' ? 'bad llm output' : 'llm error' };
|
answer: r2.answer,
|
||||||
}
|
sources,
|
||||||
|
meta: {
|
||||||
const sources = collectSources(merged, stand);
|
model: ENV.LLM_MODEL,
|
||||||
|
resource_type: resourceType,
|
||||||
return {
|
stage1_ok: stage1Ok,
|
||||||
answer: r2.answer,
|
elapsed_ms: Date.now() - t1,
|
||||||
sources,
|
},
|
||||||
meta: {
|
};
|
||||||
model: ENV.LLM_MODEL,
|
|
||||||
resource_type: resourceType,
|
|
||||||
stage1_ok: stage1Ok,
|
|
||||||
elapsed_ms: Date.now() - t1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Централизованный семафор/кэш/inflight-состояния (инициализируется один раз). */
|
/** Централизованный семафор/кэш/inflight-состояния (инициализируется один раз). */
|
||||||
function createSharedState() {
|
function createSharedState() {
|
||||||
return {
|
return {
|
||||||
semaphore: createSemaphore(ENV.ASSIST_MAX_CONCURRENT),
|
semaphore: createSemaphore(ENV.ASSIST_MAX_CONCURRENT),
|
||||||
limiter: createRateLimiter({}),
|
limiter: createRateLimiter({}),
|
||||||
cache: new Map(),
|
cache: new Map(),
|
||||||
inflight: new Map(),
|
inflight: new Map(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ожидание результата уже выполняющегося одинакового запроса. */
|
/** Ожидание результата уже выполняющегося одинакового запроса. */
|
||||||
function waitForInflight(normKey, shared, res) {
|
function waitForInflight(normKey, shared, res) {
|
||||||
return new Promise((resolvePromise) => {
|
return new Promise((resolvePromise) => {
|
||||||
const entry = shared.inflight.get(normKey);
|
const entry = shared.inflight.get(normKey);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
// запрос завершился между проверками — просто повторим обработку
|
// запрос завершился между проверками — просто повторим обработку
|
||||||
return handleAssistAgain(res); // fallback (не используется в v1)
|
return handleAssistAgain(res); // fallback (не используется в v1)
|
||||||
}
|
}
|
||||||
entry.waiters.push({
|
entry.waiters.push({
|
||||||
succeed(body) { sendJson(res, 200, body); resolvePromise(); },
|
succeed(body) { sendJson(res, 200, body); resolvePromise(); },
|
||||||
fail(status, code) { jsonError(res, status, code, 'upstream failed'); resolvePromise(); },
|
fail(status, code) { jsonError(res, status, code, 'upstream failed'); resolvePromise(); },
|
||||||
res() {},
|
res() { },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Заглушка fallback (не используется). */
|
/** Заглушка fallback (не используется). */
|
||||||
function handleAssistAgain() {}
|
function handleAssistAgain() { }
|
||||||
|
|
||||||
function statusForCode(code) {
|
function statusForCode(code) {
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case 400: return 400;
|
case 400: return 400;
|
||||||
case 413: return 413;
|
case 413: return 413;
|
||||||
case 415: return 415;
|
case 415: return 415;
|
||||||
default: return 400;
|
default: return 400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapPipelineError(code) {
|
function mapPipelineError(code) {
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case 'bad_llm_output': return 502;
|
case 'bad_llm_output': return 502;
|
||||||
case 'index_unavailable': return 503;
|
case 'index_unavailable': return 503;
|
||||||
case 'empty_content': return 502;
|
case 'empty_content': return 502;
|
||||||
case 'aborted': return 499;
|
case 'aborted': return 499;
|
||||||
case 'rate_limited': return 503;
|
case 'rate_limited': return 503;
|
||||||
default: return 502;
|
default: return 502;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendJson(res, status, obj) {
|
function sendJson(res, status, obj) {
|
||||||
const body = JSON.stringify(obj);
|
const body = JSON.stringify(obj);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.writeHead(status, {
|
res.writeHead(status, {
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
'Content-Length': Buffer.byteLength(body),
|
'Content-Length': Buffer.byteLength(body),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.end(body);
|
res.end(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
handleAssist,
|
handleAssist,
|
||||||
createSharedState,
|
createSharedState,
|
||||||
runPipeline,
|
runPipeline,
|
||||||
fetchIndex,
|
fetchIndex,
|
||||||
clientIp,
|
clientIp,
|
||||||
STAND_RE,
|
STAND_RE,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ const server = http.createServer((req, res) => {
|
|||||||
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||||
res.end(JSON.stringify({ error: 'internal_error', message: 'assist failed' }));
|
res.end(JSON.stringify({ error: 'internal_error', message: 'assist failed' }));
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) { }
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,12 @@
|
|||||||
const { llmChat } = require('./assist.js');
|
const { llmChat } = require('./assist.js');
|
||||||
|
|
||||||
const SYSTEM_PROMPT =
|
const SYSTEM_PROMPT =
|
||||||
'Ты — помощник по документации Terraform-провайдера Nubes. ' +
|
'Ты — помощник по документации Terraform-провайдера Nubes. ' +
|
||||||
'Определи, к какому типу ресурса относится вопрос пользователя. ' +
|
'Определи, к какому типу ресурса относится вопрос пользователя. ' +
|
||||||
'Отвечай строго JSON вида {"resource_type":"nubes_<имя>"}. ' +
|
'Отвечай строго JSON вида {"resource_type":"nubes_<имя>"}. ' +
|
||||||
'Используй ТОЛЬКО типы из списка ниже. ' +
|
'Используй ТОЛЬКО типы из списка ниже. ' +
|
||||||
'Если вопрос не относится ни к одному типу или тип не из списка — верни {"resource_type":""}. ' +
|
'Если вопрос не относится ни к одному типу или тип не из списка — верни {"resource_type":""}. ' +
|
||||||
'Не выдумывай типы.';
|
'Не выдумывай типы.';
|
||||||
|
|
||||||
const DEFAULT_MAX_TOKENS = 256;
|
const DEFAULT_MAX_TOKENS = 256;
|
||||||
const DEFAULT_MODEL = 'gpt-oss-120b';
|
const DEFAULT_MODEL = 'gpt-oss-120b';
|
||||||
@@ -36,19 +36,19 @@ const DEFAULT_MODEL = 'gpt-oss-120b';
|
|||||||
* @returns {Array<{role:string, content:string}>}
|
* @returns {Array<{role:string, content:string}>}
|
||||||
*/
|
*/
|
||||||
function buildStage1Messages(question, resourceTypesSet) {
|
function buildStage1Messages(question, resourceTypesSet) {
|
||||||
const types = [...new Set(resourceTypesSet || [])].map((t) => String(t)).sort();
|
const types = [...new Set(resourceTypesSet || [])].map((t) => String(t)).sort();
|
||||||
const list = types.length > 0 ? types.join('\n') : '(пусто)';
|
const list = types.length > 0 ? types.join('\n') : '(пусто)';
|
||||||
|
|
||||||
const userContent =
|
const userContent =
|
||||||
'Доступные типы ресурсов (используй ТОЛЬКО из этого списка):\n' +
|
'Доступные типы ресурсов (используй ТОЛЬКО из этого списка):\n' +
|
||||||
list +
|
list +
|
||||||
'\n\nВопрос пользователя:\n' +
|
'\n\nВопрос пользователя:\n' +
|
||||||
String(question ?? '');
|
String(question ?? '');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ role: 'system', content: SYSTEM_PROMPT },
|
{ role: 'system', content: SYSTEM_PROMPT },
|
||||||
{ role: 'user', content: userContent },
|
{ role: 'user', content: userContent },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,12 +59,12 @@ function buildStage1Messages(question, resourceTypesSet) {
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function extractJsonString(raw) {
|
function extractJsonString(raw) {
|
||||||
const text = String(raw ?? '').trim();
|
const text = String(raw ?? '').trim();
|
||||||
const stripped = text.replace(/```(?:json)?\s*/gi, '').replace(/```/g, '');
|
const stripped = text.replace(/```(?:json)?\s*/gi, '').replace(/```/g, '');
|
||||||
const start = stripped.indexOf('{');
|
const start = stripped.indexOf('{');
|
||||||
const end = stripped.lastIndexOf('}');
|
const end = stripped.lastIndexOf('}');
|
||||||
if (start === -1 || end === -1 || end < start) return '';
|
if (start === -1 || end === -1 || end < start) return '';
|
||||||
return stripped.slice(start, end + 1);
|
return stripped.slice(start, end + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,18 +75,18 @@ function extractJsonString(raw) {
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function parseResourceType(content) {
|
function parseResourceType(content) {
|
||||||
const jsonStr = extractJsonString(content);
|
const jsonStr = extractJsonString(content);
|
||||||
if (!jsonStr) return '';
|
if (!jsonStr) return '';
|
||||||
let obj;
|
let obj;
|
||||||
try {
|
try {
|
||||||
obj = JSON.parse(jsonStr);
|
obj = JSON.parse(jsonStr);
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
if (!obj || typeof obj !== 'object') return '';
|
if (!obj || typeof obj !== 'object') return '';
|
||||||
const raw = obj.resource_type;
|
const raw = obj.resource_type;
|
||||||
if (typeof raw !== 'string') return '';
|
if (typeof raw !== 'string') return '';
|
||||||
return raw.toLowerCase().trim();
|
return raw.toLowerCase().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,80 +112,80 @@ function parseResourceType(content) {
|
|||||||
* @returns {Promise<{ok:boolean, resourceType?:string|null, error?:string}>}
|
* @returns {Promise<{ok:boolean, resourceType?:string|null, error?:string}>}
|
||||||
*/
|
*/
|
||||||
async function stage1ExtractResourceType(question, {
|
async function stage1ExtractResourceType(question, {
|
||||||
resourceTypesSet,
|
resourceTypesSet,
|
||||||
baseUrl = process.env.LLM_BASE_URL || '',
|
baseUrl = process.env.LLM_BASE_URL || '',
|
||||||
apiKey = process.env.LLM_API_KEY || '',
|
apiKey = process.env.LLM_API_KEY || '',
|
||||||
model = process.env.LLM_MODEL || DEFAULT_MODEL,
|
model = process.env.LLM_MODEL || DEFAULT_MODEL,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
controller,
|
controller,
|
||||||
}) {
|
}) {
|
||||||
const types = new Set(
|
const types = new Set(
|
||||||
[...new Set(resourceTypesSet || [])].map((t) => String(t).toLowerCase().trim())
|
[...new Set(resourceTypesSet || [])].map((t) => String(t).toLowerCase().trim())
|
||||||
);
|
);
|
||||||
|
|
||||||
if (types.size === 0) {
|
if (types.size === 0) {
|
||||||
return { ok: false, error: 'no_resource_types' };
|
return { ok: false, error: 'no_resource_types' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = buildStage1Messages(question, types);
|
const messages = buildStage1Messages(question, types);
|
||||||
|
|
||||||
const attempt = async (maxTokens) => {
|
const attempt = async (maxTokens) => {
|
||||||
const params = {
|
const params = {
|
||||||
messages,
|
messages,
|
||||||
maxTokens,
|
maxTokens,
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
responseFormat: { type: 'json_object' },
|
responseFormat: { type: 'json_object' },
|
||||||
controller,
|
controller,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
model,
|
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;
|
let content;
|
||||||
if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
try {
|
||||||
ctrl = new AbortController();
|
content = await attempt(DEFAULT_MAX_TOKENS);
|
||||||
timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
} catch (err) {
|
||||||
const forward = () => ctrl.abort();
|
if (err && err.code === 'empty_content') {
|
||||||
if (controller) {
|
try {
|
||||||
if (controller.signal.aborted) ctrl.abort();
|
content = await attempt(DEFAULT_MAX_TOKENS * 2);
|
||||||
else controller.signal.addEventListener('abort', forward, { once: true });
|
} catch (err2) {
|
||||||
}
|
return { ok: false, error: err2 && err2.code ? err2.code : 'stage1_retry_failed' };
|
||||||
params.controller = ctrl;
|
}
|
||||||
try {
|
} else {
|
||||||
return await llmChat(params);
|
return { ok: false, error: err && err.code ? err.code : 'stage1_failed' };
|
||||||
} finally {
|
}
|
||||||
clearTimeout(timer);
|
|
||||||
if (controller) controller.signal.removeEventListener('abort', forward);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return llmChat(params);
|
|
||||||
};
|
|
||||||
|
|
||||||
let content;
|
const resourceType = parseResourceType(content);
|
||||||
try {
|
|
||||||
content = await attempt(DEFAULT_MAX_TOKENS);
|
if (resourceType && types.has(resourceType)) {
|
||||||
} catch (err) {
|
return { ok: true, resourceType };
|
||||||
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);
|
return { ok: true, resourceType: null };
|
||||||
|
|
||||||
if (resourceType && types.has(resourceType)) {
|
|
||||||
return { ok: true, resourceType };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, resourceType: null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
stage1ExtractResourceType,
|
stage1ExtractResourceType,
|
||||||
buildStage1Messages,
|
buildStage1Messages,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user