'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, };