feat(stage1): этап 1 — извлечение resource_type через LLM по списку реальных ресурсов
This commit is contained in:
@@ -270,7 +270,8 @@ function createSemaphore(max) {
|
|||||||
* baseUrl?:string,
|
* baseUrl?:string,
|
||||||
* apiKey?:string,
|
* apiKey?:string,
|
||||||
* model?:string,
|
* model?:string,
|
||||||
* temperature?:number
|
* temperature?:number,
|
||||||
|
* responseFormat?:{type:string} // напр. {type:'json_object'}
|
||||||
* }} params
|
* }} params
|
||||||
* @returns {Promise<string>} — содержимое choices[0].message.content
|
* @returns {Promise<string>} — содержимое choices[0].message.content
|
||||||
*/
|
*/
|
||||||
@@ -282,6 +283,7 @@ async function llmChat({
|
|||||||
apiKey = ENV.LLM_API_KEY,
|
apiKey = ENV.LLM_API_KEY,
|
||||||
model = ENV.LLM_MODEL,
|
model = ENV.LLM_MODEL,
|
||||||
temperature,
|
temperature,
|
||||||
|
responseFormat,
|
||||||
}) {
|
}) {
|
||||||
const base = String(baseUrl).replace(/\/+$/, '');
|
const base = String(baseUrl).replace(/\/+$/, '');
|
||||||
const url = `${base}/v1/chat/completions`;
|
const url = `${base}/v1/chat/completions`;
|
||||||
@@ -293,6 +295,9 @@ async function llmChat({
|
|||||||
if (temperature !== undefined && temperature !== null) {
|
if (temperature !== undefined && temperature !== null) {
|
||||||
payload.temperature = temperature;
|
payload.temperature = temperature;
|
||||||
}
|
}
|
||||||
|
if (responseFormat !== undefined && responseFormat !== null) {
|
||||||
|
payload.response_format = responseFormat;
|
||||||
|
}
|
||||||
|
|
||||||
const signal = controller ? controller.signal : undefined;
|
const signal = controller ? controller.signal : undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -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>|string[]|Iterable<string>} 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>|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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user