Files
tf_docs/stage2.js
T

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