275 lines
10 KiB
JavaScript
275 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* stage2.js — шаг 6: объединение результатов поиска + этап 2 (генерация ответа).
|
|
*
|
|
* Объединяет два поиска (по токенам исходного запроса и после этапа 1),
|
|
* добавляет обязательную страницу ресурса, затем вызывает LLM для генерации
|
|
* итогового ответа по СТРОГОМУ шаблону.
|
|
*
|
|
* Экспорты:
|
|
* - mergeSearchResults(...)
|
|
* - buildContext(...)
|
|
* - collectSources(...)
|
|
* - sanitizeSourceUrl(...)
|
|
* - stage2Generate(...)
|
|
*/
|
|
|
|
const { llmChat } = require('./assist.js');
|
|
const { search, stripHtml, resourceBaseSegment } = require('./search.js');
|
|
|
|
const DEFAULT_MODEL = 'gpt-oss-120b';
|
|
|
|
// Заголовки, которые ОБЯЗАН содержать ответ этапа 2 (П.12 брифа).
|
|
const REQUIRED_HEADERS = [
|
|
'Роль',
|
|
'Задача',
|
|
'Контекст',
|
|
'Требования',
|
|
'Ограничения',
|
|
];
|
|
|
|
/**
|
|
* Проверяет URL источника на допустимость (П.16 брифа): отбрасывает ссылки,
|
|
* содержащие "//", ":" или "..".
|
|
*
|
|
* @param {string} url
|
|
* @returns {boolean}
|
|
*/
|
|
function isValidSourceUrl(url) {
|
|
if (!url || typeof url !== 'string') return false;
|
|
if (url.includes('//') || url.includes(':') || url.includes('..')) return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Собирает безопасный URL источника из stand и location индекса.
|
|
*
|
|
* @param {string} stand - имя стенда (напр. nubes-test).
|
|
* @param {string} location - поле location из индекса (напр. "postgres/#argument-reference").
|
|
* @returns {string|null} - "/<stand>/<location>" или null, если недопустимо.
|
|
*/
|
|
function sanitizeSourceUrl(stand, location) {
|
|
const clean = sanitizeLocation(location);
|
|
if (clean === null) return null;
|
|
const url = `/${stand}/${clean}`;
|
|
return isValidSourceUrl(url) ? url : null;
|
|
}
|
|
|
|
/**
|
|
* Нормализует location: убирает ведущие/хвостовые слэши.
|
|
* Возвращает null для заведомо невалидных (содержат "..", ":").
|
|
*
|
|
* @param {string} location
|
|
* @returns {string|null}
|
|
*/
|
|
function sanitizeLocation(location) {
|
|
const s = String(location ?? '').trim();
|
|
if (!s) return null;
|
|
if (s.includes('..') || s.includes(':')) return null;
|
|
return s.replace(/^\/+|\/+$/g, '');
|
|
}
|
|
|
|
/**
|
|
* Объединяет кандидатов из двух поисков (П.8 брифа):
|
|
* - повторы убираются по location;
|
|
* - оценка записи = максимум из оценок в двух поисках;
|
|
* - страница ресурса (если resource_type найден) ставится ПЕРВОЙ и входит в K.
|
|
*
|
|
* @param {object} index - индекс из loadIndex().
|
|
* @param {string} query - исходный запрос пользователя.
|
|
* @param {string|null} resourceType - nubes_* из этапа 1 (или null).
|
|
* @param {object} [opts]
|
|
* @param {number} [opts.topK] - размер итогового контекста (по умолч. 5).
|
|
* @returns {Array<{location:string,title:string,text:string,score:number}>}
|
|
*/
|
|
function mergeSearchResults(index, query, resourceType, opts = {}) {
|
|
const topK = Number.isFinite(opts.topK) && opts.topK > 0 ? Math.floor(opts.topK) : 5;
|
|
|
|
// 1) поиск по токенам исходного запроса (всегда).
|
|
const tokenResults = search(index, query, { topK: 50 });
|
|
|
|
// 2) поиск после этапа 1: если resource_type есть, ищем по его имени.
|
|
let resourceResults = [];
|
|
if (resourceType) {
|
|
// Ищем и по полному resource_type, и по базовому ресурсу (подресурс → родитель).
|
|
resourceResults = search(index, resourceType, { topK: 50 });
|
|
const base = resourceBaseSegment(resourceType);
|
|
if (base && base !== resourceType) {
|
|
const baseResults = search(index, base, { topK: 50 });
|
|
resourceResults = resourceResults.concat(baseResults);
|
|
}
|
|
}
|
|
|
|
// Объединяем по location, оценка = max.
|
|
const byLocation = new Map();
|
|
const add = (arr) => {
|
|
for (const r of arr) {
|
|
const key = r.location || '';
|
|
const prev = byLocation.get(key);
|
|
if (!prev || r.score > prev.score) {
|
|
byLocation.set(key, r);
|
|
}
|
|
}
|
|
};
|
|
add(tokenResults);
|
|
add(resourceResults);
|
|
|
|
// Собираем отсортированный массив.
|
|
const merged = [...byLocation.values()].sort(
|
|
(a, b) => b.score - a.score || a.location.localeCompare(b.location)
|
|
);
|
|
|
|
// Страница ресурса — обязательно первой (П.5), если resource_type найден.
|
|
const result = [];
|
|
if (resourceType) {
|
|
const resourcePage = merged.find((r) => isResourcePage(r.location, resourceType));
|
|
if (resourcePage) {
|
|
result.push(resourcePage);
|
|
}
|
|
}
|
|
|
|
// Дополняем до K без повторов.
|
|
for (const r of merged) {
|
|
if (result.length >= topK) break;
|
|
if (result.includes(r)) continue;
|
|
result.push(r);
|
|
}
|
|
|
|
return result.slice(0, topK);
|
|
}
|
|
|
|
/**
|
|
* Проверяет, что location — страница заданного resourceType:
|
|
* страница ресурса имеет вид "<имя_ресурса_без_nubes_>/" или начинается с него.
|
|
* Сопоставляем через имя из resourceType: nubes_postgres -> postgres.
|
|
*
|
|
* @param {string} location
|
|
* @param {string} resourceType - nubes_* имя.
|
|
* @returns {boolean}
|
|
*/
|
|
function isResourcePage(location, resourceType) {
|
|
const pageName = resourceType.replace(/^nubes_/, '');
|
|
if (!pageName) return false;
|
|
const seg = String(location || '').split('/')[0].trim();
|
|
return seg === pageName;
|
|
}
|
|
|
|
/**
|
|
* Собирает контекст (текстовые фрагменты) из объединённых результатов.
|
|
* HTML из text удаляется. Каждый фрагмент обрезается до maxChars.
|
|
*
|
|
* @param {Array<{location:string,title:string,text:string}>} entries
|
|
* @param {number} [maxChars] - макс. символов на фрагмент (по умолч. 1500).
|
|
* @returns {Array<{location:string,title:string,text:string}>}
|
|
*/
|
|
function buildContext(entries, maxChars = 1500) {
|
|
return entries.map((e) => ({
|
|
location: e.location,
|
|
title: e.title || '',
|
|
text: stripHtml(e.text || '').slice(0, maxChars),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Собирает список sources (П.16): /<stand>/ + location, отбрасывая недопустимые.
|
|
*
|
|
* @param {Array<{location:string}>} entries
|
|
* @param {string} stand
|
|
* @returns {Array<{title:string, url:string}>}
|
|
*/
|
|
function collectSources(entries, stand) {
|
|
const out = [];
|
|
for (const e of entries) {
|
|
const url = sanitizeSourceUrl(stand, e.location);
|
|
if (url) {
|
|
out.push({ title: stripHtml(e.title || ''), url });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Системный промпт этапа 2: строгий шаблон с обязательными заголовками.
|
|
*/
|
|
const STAGE2_SYSTEM_PROMPT =
|
|
'Ты — ассистент документации Terraform-провайдера Nubes. ' +
|
|
'Составь ответ СТРОГО по шаблону с заголовками-маркерами, каждый на новой строке:\n' +
|
|
'Роль:\nЗадача:\nКонтекст:\nТребования:\nОграничения:\n' +
|
|
'Используй ТОЛЬКО факты из блока ДОКУМЕНТАЦИЯ ниже. ' +
|
|
'Не выдумывай ресурсов, атрибутов и значений, которых нет в документации. ' +
|
|
'Отвечай на русском языке.';
|
|
|
|
/**
|
|
* Этап 2: генерация итогового ответа по строгому шаблону.
|
|
*
|
|
* @param {object} params
|
|
* @param {string} params.question - исходный вопрос.
|
|
* @param {Array<{location:string,title:string,text:string}>} params.context - фрагменты.
|
|
* @param {string} [params.baseUrl]
|
|
* @param {string} [params.apiKey]
|
|
* @param {string} [params.model]
|
|
* @param {number} [params.maxTokens]
|
|
* @param {AbortController} [params.controller]
|
|
* @returns {Promise<{ok:true, answer:string} | {ok:false, error:string}>}
|
|
*/
|
|
async function stage2Generate({
|
|
question,
|
|
context,
|
|
baseUrl = process.env.LLM_BASE_URL || '',
|
|
apiKey = process.env.LLM_API_KEY || '',
|
|
model = process.env.LLM_MODEL || DEFAULT_MODEL,
|
|
maxTokens = 1024,
|
|
controller,
|
|
}) {
|
|
const docsBlock = context
|
|
.map((c, i) => `[${i + 1}] ${c.title}\n${c.text}`)
|
|
.join('\n\n');
|
|
|
|
const userContent =
|
|
'ДОКУМЕНТАЦИЯ:\n' +
|
|
(docsBlock || '(нет релевантных фрагментов)') +
|
|
'\n\nВОПРОС ПОЛЬЗОВАТЕЛЯ:\n' +
|
|
String(question ?? '');
|
|
|
|
const messages = [
|
|
{ role: 'system', content: STAGE2_SYSTEM_PROMPT },
|
|
{ role: 'user', content: userContent },
|
|
];
|
|
|
|
let content;
|
|
try {
|
|
content = await llmChat({
|
|
messages,
|
|
maxTokens,
|
|
temperature: 0.2,
|
|
controller,
|
|
baseUrl,
|
|
apiKey,
|
|
model,
|
|
});
|
|
} catch (err) {
|
|
return { ok: false, error: err && err.code ? err.code : 'stage2_failed' };
|
|
}
|
|
|
|
// Проверяем наличие обязательных заголовков (П.12).
|
|
const missing = REQUIRED_HEADERS.filter((h) => !content.includes(h + ':'));
|
|
if (missing.length > 0) {
|
|
return { ok: false, error: 'bad_llm_output' };
|
|
}
|
|
|
|
return { ok: true, answer: content };
|
|
}
|
|
|
|
module.exports = {
|
|
mergeSearchResults,
|
|
buildContext,
|
|
collectSources,
|
|
sanitizeSourceUrl,
|
|
sanitizeLocation,
|
|
isValidSourceUrl,
|
|
isResourcePage,
|
|
stage2Generate,
|
|
REQUIRED_HEADERS,
|
|
};
|