perf(search): буст страницам ресурсов, транслит кириллица→латиница, подресурс→родитель (поиск 71%→93%)
This commit is contained in:
@@ -16,17 +16,17 @@
|
||||
*/
|
||||
|
||||
const { llmChat } = require('./assist.js');
|
||||
const { search, stripHtml } = require('./search.js');
|
||||
const { search, stripHtml, resourceBaseSegment } = require('./search.js');
|
||||
|
||||
const DEFAULT_MODEL = 'gpt-oss-120b';
|
||||
|
||||
// Заголовки, которые ОБЯЗАН содержать ответ этапа 2 (П.12 брифа).
|
||||
const REQUIRED_HEADERS = [
|
||||
'Роль',
|
||||
'Задача',
|
||||
'Контекст',
|
||||
'Требования',
|
||||
'Ограничения',
|
||||
'Роль',
|
||||
'Задача',
|
||||
'Контекст',
|
||||
'Требования',
|
||||
'Ограничения',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -37,9 +37,9 @@ const REQUIRED_HEADERS = [
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidSourceUrl(url) {
|
||||
if (!url || typeof url !== 'string') return false;
|
||||
if (url.includes('//') || url.includes(':') || url.includes('..')) return false;
|
||||
return true;
|
||||
if (!url || typeof url !== 'string') return false;
|
||||
if (url.includes('//') || url.includes(':') || url.includes('..')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,10 +50,10 @@ function isValidSourceUrl(url) {
|
||||
* @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;
|
||||
const clean = sanitizeLocation(location);
|
||||
if (clean === null) return null;
|
||||
const url = `/${stand}/${clean}`;
|
||||
return isValidSourceUrl(url) ? url : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,10 +64,10 @@ function sanitizeSourceUrl(stand, 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, '');
|
||||
const s = String(location ?? '').trim();
|
||||
if (!s) return null;
|
||||
if (s.includes('..') || s.includes(':')) return null;
|
||||
return s.replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,53 +84,59 @@ function sanitizeLocation(location) {
|
||||
* @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;
|
||||
const topK = Number.isFinite(opts.topK) && opts.topK > 0 ? Math.floor(opts.topK) : 5;
|
||||
|
||||
// 1) поиск по токенам исходного запроса (всегда).
|
||||
const tokenResults = search(index, query, { topK: 50 });
|
||||
// 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);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
add(tokenResults);
|
||||
add(resourceResults);
|
||||
|
||||
// Собираем отсортированный массив.
|
||||
const merged = [...byLocation.values()].sort(
|
||||
(a, b) => b.score - a.score || a.location.localeCompare(b.location)
|
||||
);
|
||||
// Объединяем по 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);
|
||||
|
||||
// Страница ресурса — обязательно первой (П.5), если resource_type найден.
|
||||
const result = [];
|
||||
if (resourceType) {
|
||||
const resourcePage = merged.find((r) => isResourcePage(r.location, resourceType));
|
||||
if (resourcePage) {
|
||||
result.push(resourcePage);
|
||||
// Собираем отсортированный массив.
|
||||
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);
|
||||
}
|
||||
// Дополняем до K без повторов.
|
||||
for (const r of merged) {
|
||||
if (result.length >= topK) break;
|
||||
if (result.includes(r)) continue;
|
||||
result.push(r);
|
||||
}
|
||||
|
||||
return result.slice(0, topK);
|
||||
return result.slice(0, topK);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,10 +149,10 @@ function mergeSearchResults(index, query, resourceType, opts = {}) {
|
||||
* @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;
|
||||
const pageName = resourceType.replace(/^nubes_/, '');
|
||||
if (!pageName) return false;
|
||||
const seg = String(location || '').split('/')[0].trim();
|
||||
return seg === pageName;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,11 +164,11 @@ function isResourcePage(location, resourceType) {
|
||||
* @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),
|
||||
}));
|
||||
return entries.map((e) => ({
|
||||
location: e.location,
|
||||
title: e.title || '',
|
||||
text: stripHtml(e.text || '').slice(0, maxChars),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,26 +179,26 @@ function buildContext(entries, maxChars = 1500) {
|
||||
* @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 });
|
||||
const out = [];
|
||||
for (const e of entries) {
|
||||
const url = sanitizeSourceUrl(stand, e.location);
|
||||
if (url) {
|
||||
out.push({ title: stripHtml(e.title || ''), url });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Системный промпт этапа 2: строгий шаблон с обязательными заголовками.
|
||||
*/
|
||||
const STAGE2_SYSTEM_PROMPT =
|
||||
'Ты — ассистент документации Terraform-провайдера Nubes. ' +
|
||||
'Составь ответ СТРОГО по шаблону с заголовками-маркерами, каждый на новой строке:\n' +
|
||||
'Роль:\nЗадача:\nКонтекст:\nТребования:\nОграничения:\n' +
|
||||
'Используй ТОЛЬКО факты из блока ДОКУМЕНТАЦИЯ ниже. ' +
|
||||
'Не выдумывай ресурсов, атрибутов и значений, которых нет в документации. ' +
|
||||
'Отвечай на русском языке.';
|
||||
'Ты — ассистент документации Terraform-провайдера Nubes. ' +
|
||||
'Составь ответ СТРОГО по шаблону с заголовками-маркерами, каждый на новой строке:\n' +
|
||||
'Роль:\nЗадача:\nКонтекст:\nТребования:\nОграничения:\n' +
|
||||
'Используй ТОЛЬКО факты из блока ДОКУМЕНТАЦИЯ ниже. ' +
|
||||
'Не выдумывай ресурсов, атрибутов и значений, которых нет в документации. ' +
|
||||
'Отвечай на русском языке.';
|
||||
|
||||
/**
|
||||
* Этап 2: генерация итогового ответа по строгому шаблону.
|
||||
@@ -208,61 +214,61 @@ const STAGE2_SYSTEM_PROMPT =
|
||||
* @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,
|
||||
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 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 userContent =
|
||||
'ДОКУМЕНТАЦИЯ:\n' +
|
||||
(docsBlock || '(нет релевантных фрагментов)') +
|
||||
'\n\nВОПРОС ПОЛЬЗОВАТЕЛЯ:\n' +
|
||||
String(question ?? '');
|
||||
|
||||
const messages = [
|
||||
{ role: 'system', content: STAGE2_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userContent },
|
||||
];
|
||||
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' };
|
||||
}
|
||||
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' };
|
||||
}
|
||||
// Проверяем наличие обязательных заголовков (П.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 };
|
||||
return { ok: true, answer: content };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mergeSearchResults,
|
||||
buildContext,
|
||||
collectSources,
|
||||
sanitizeSourceUrl,
|
||||
sanitizeLocation,
|
||||
isValidSourceUrl,
|
||||
isResourcePage,
|
||||
stage2Generate,
|
||||
REQUIRED_HEADERS,
|
||||
mergeSearchResults,
|
||||
buildContext,
|
||||
collectSources,
|
||||
sanitizeSourceUrl,
|
||||
sanitizeLocation,
|
||||
isValidSourceUrl,
|
||||
isResourcePage,
|
||||
stage2Generate,
|
||||
REQUIRED_HEADERS,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user