Files
Repinoid 12d29ee98d fix: поиск по подресурсам + реальный per-call таймаут ЛЛМ
search(resourceBaseSegment): суффиксы-подресурсы перебираются по убыванию
длины (KNOWN_RESOURCE_SUBS). Иначе "_user" матчился раньше "_sub_user" и
nubes_s3_sub_user давал "s3_sub" вместо "s3" — страница ресурса не находилась.

assist(llmChat): реально применяет ASSIST_LLM_TIMEOUT_MS — таймаут ОДНОГО
вызова (покрывает и чтение тела ответа). Таймаут возвращает отдельный код
llm_timeout, отмена вызывающим (дедлайн/закрытие соединения) — по-прежнему
aborted, чтобы stage2 различал причины.

stage1: убран дублирующий AbortController (таймаут теперь только в llmChat).

orchestrator: llm_timeout -> HTTP 504.
2026-10-12 07:51:48 +03:00

179 lines
6.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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]
* @param {object} [opts.usageRef]
* @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,
usageRef,
}) {
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);
// Индивидуальный таймаут одного вызова ЛЛМ и составной сигнал отмены
// реализованы ВНУТРИ llmChat (единая точка). Здесь только прокидываем опции,
// чтобы не создавать ДВОЙНОЙ AbortController. timeoutMs === undefined →
// llmChat применит дефолт ASSIST_LLM_TIMEOUT_MS.
const attempt = async (maxTokens) => llmChat({
messages,
maxTokens,
temperature: 0,
responseFormat: { type: 'json_object' },
controller,
timeoutMs,
baseUrl,
apiKey,
model,
usageRef,
});
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,
};