chore: форматирование модулей + план по шагам

This commit is contained in:
Repinoid
2026-10-07 14:11:35 +03:00
parent 2136536b0d
commit c59f0245b5
6 changed files with 808 additions and 479 deletions
+162
View File
@@ -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).
+196 -29
View File
@@ -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": ""
}
]
+134 -134
View File
@@ -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); });
+216 -216
View File
@@ -37,32 +37,32 @@ const STAND_RE = /^[A-Za-z0-9._-]+$/;
* @returns {Promise<object|null>} - 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,
};
+1 -1
View File
@@ -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;
}
+99 -99
View File
@@ -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,
};