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).
+195 -28
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": "Как создать виртуальную машину в облаке 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
View File
@@ -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
View File
@@ -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,
}; };
+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.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;
} }
+99 -99
View File
@@ -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,
}; };