diff --git a/stage2.js b/stage2.js new file mode 100644 index 0000000..cee223c --- /dev/null +++ b/stage2.js @@ -0,0 +1,268 @@ +'use strict'; + +/** + * stage2.js — шаг 6: объединение результатов поиска + этап 2 (генерация ответа). + * + * Объединяет два поиска (по токенам исходного запроса и после этапа 1), + * добавляет обязательную страницу ресурса, затем вызывает LLM для генерации + * итогового ответа по СТРОГОМУ шаблону. + * + * Экспорты: + * - mergeSearchResults(...) + * - buildContext(...) + * - collectSources(...) + * - sanitizeSourceUrl(...) + * - stage2Generate(...) + */ + +const { llmChat } = require('./assist.js'); +const { search, stripHtml } = 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} - "//" или 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) { + resourceResults = search(index, resourceType, { topK: 50 }); + } + + // Объединяем по 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): // + 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, +};