From 4e8d115c263a51cf96af99e7e7fdd94f05a6b6d4 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Wed, 7 Oct 2026 13:48:19 +0300 Subject: [PATCH] =?UTF-8?q?feat(stage1):=20=D1=8D=D1=82=D0=B0=D0=BF=201=20?= =?UTF-8?q?=E2=80=94=20=D0=B8=D0=B7=D0=B2=D0=BB=D0=B5=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20resource=5Ftype=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20LLM=20=D0=BF=D0=BE=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D1=83=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D1=83=D1=80=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assist.js | 7 +- stage1.js | 191 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 stage1.js diff --git a/assist.js b/assist.js index 0643c6f..1166d09 100644 --- a/assist.js +++ b/assist.js @@ -270,7 +270,8 @@ function createSemaphore(max) { * baseUrl?:string, * apiKey?:string, * model?:string, - * temperature?:number + * temperature?:number, + * responseFormat?:{type:string} // напр. {type:'json_object'} * }} params * @returns {Promise} — содержимое choices[0].message.content */ @@ -282,6 +283,7 @@ async function llmChat({ apiKey = ENV.LLM_API_KEY, model = ENV.LLM_MODEL, temperature, + responseFormat, }) { const base = String(baseUrl).replace(/\/+$/, ''); const url = `${base}/v1/chat/completions`; @@ -293,6 +295,9 @@ async function llmChat({ if (temperature !== undefined && temperature !== null) { payload.temperature = temperature; } + if (responseFormat !== undefined && responseFormat !== null) { + payload.response_format = responseFormat; + } const signal = controller ? controller.signal : undefined; diff --git a/stage1.js b/stage1.js new file mode 100644 index 0000000..66299e9 --- /dev/null +++ b/stage1.js @@ -0,0 +1,191 @@ +'use strict'; + +/** + * stage1.js — этап 1 AI-ассистента документации Nubes. + * + * Извлекает тип ресурса (`resource_type`) из вопроса пользователя через LLM, + * опираясь на список реальных ресурсов (из search_index.json того же стенда). + * + * Использует llmChat из ./assist.js (единый вызов LLM). + * + * Экспорты: + * - stage1ExtractResourceType(question, opts) + * - buildStage1Messages(question, resourceTypesSet) + */ + +const { llmChat } = require('./assist.js'); + +const SYSTEM_PROMPT = + 'Ты — помощник по документации Terraform-провайдера Nubes. ' + + 'Определи, к какому типу ресурса относится вопрос пользователя. ' + + 'Отвечай строго JSON вида {"resource_type":"nubes_<имя>"}. ' + + 'Используй ТОЛЬКО типы из списка ниже. ' + + 'Если вопрос не относится ни к одному типу или тип не из списка — верни {"resource_type":""}. ' + + 'Не выдумывай типы.'; + +const DEFAULT_MAX_TOKENS = 256; +const DEFAULT_MODEL = 'gpt-oss-120b'; + +/** + * Собирает массив сообщений для вызова LLM (этап 1). + * Системный промпт (на русском) зафиксирован; в user-сообщение попадает + * отсортированный список реальных типов ресурсов, затем сам вопрос. + * + * @param {string} question - Исходный вопрос пользователя. + * @param {Set|string[]|Iterable} resourceTypesSet + * @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 userContent = + 'Доступные типы ресурсов (используй ТОЛЬКО из этого списка):\n' + + list + + '\n\nВопрос пользователя:\n' + + String(question ?? ''); + + return [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: userContent }, + ]; +} + +/** + * Извлекает JSON-строку из ответа LLM: убирает markdown-ограждения и берёт + * первую подстроку от первой `{` до последней `}`. + * + * @param {string} raw - Сырой content из ответа LLM. + * @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); +} + +/** + * Разбирает resource_type из ответа LLM, нормализуя (lowercase + trim). + * При ошибках парсинга возвращает ''. + * + * @param {string} content + * @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(); +} + +/** + * Этап 1: определение типа ресурса из вопроса через LLM. + * + * Логика: + * 1) resourceTypesSet пуст → {ok:false, error:'no_resource_types'}. + * 2) LLM с response_format json_object, temperature 0, maxTokens 256. + * 3) Распарсить JSON, нормализовать resource_type. + * 4) Тип есть и в списке → {ok:true, resourceType}. + * 5) Пустой/неизвестный/не в списке → {ok:true, resourceType:null}. + * 6) empty_content → повтор ОДИН раз с maxTokens x2. + * 7) Другая ошибка → {ok:false, error}. + * + * @param {string} question + * @param {object} opts + * @param {Set|string[]} opts.resourceTypesSet + * @param {string} [opts.baseUrl] + * @param {string} [opts.apiKey] + * @param {string} [opts.model] + * @param {number} [opts.timeoutMs] + * @param {AbortController} [opts.controller] + * @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, +}) { + const types = new Set( + [...new Set(resourceTypesSet || [])].map((t) => String(t).toLowerCase().trim()) + ); + + if (types.size === 0) { + return { ok: false, error: 'no_resource_types' }; + } + + const messages = buildStage1Messages(question, types); + + 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 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 }; + } + + return { ok: true, resourceType: null }; +} + +module.exports = { + stage1ExtractResourceType, + buildStage1Messages, +};