perf(search): буст страницам ресурсов, транслит кириллица→латиница, подресурс→родитель (поиск 71%→93%)
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
# Оценка качества на эталонном наборе (шаг 9, первый прогон)
|
||||||
|
|
||||||
|
Дата: 2026-10-07
|
||||||
|
|
||||||
|
## Метрики (28 вопросов)
|
||||||
|
|
||||||
|
| Метрика | Значение | Целевой порог (бриф П.7) |
|
||||||
|
|---|---|---|
|
||||||
|
| Точность этапа 1 (resource_type) | **79% (22/28)** | ≥ 95% (с учётом повторов) |
|
||||||
|
| Поиск: ожидаемая страница в top-5 | **71% (20/28)** | ≥ 80% |
|
||||||
|
|
||||||
|
## Выявленные проблемы поиска
|
||||||
|
|
||||||
|
1. **`_manual` и служебные (`curated`/`30_registry`) страницы конкурируют со страницами ресурсов** в скоринге. Пример: «создать постгрес» → `postgres_manual`, «Node.js приложение» → `curated`.
|
||||||
|
2. **Кириллица ↔ латиница**: «постгрес» не токенизируется в `postgres` (слова разные). Нужен словарь-маппинг частых терминов.
|
||||||
|
3. **Подресурсы теряют родителя**: `nubes_postgres_database`, `nubes_mongodb_user`, `nubes_kafka_topic` при поиске по токенам не приводят на страницу `postgres`/`mongodb`/`kafka` (в тексте нет буквального токена `postgres_database`).
|
||||||
|
|
||||||
|
## Примеры ошибок
|
||||||
|
|
||||||
|
| Вопрос | resource_type (верно?) | Проблема |
|
||||||
|
|---|---|---|
|
||||||
|
| «создать постгрес базу» | `nubes_postgres_database` ✅ | страница `postgres_manual` вместо `postgres` |
|
||||||
|
| «пользователь пароль MongoDB» | `nubes_mongodb_user` ✅ | страница `curated` вместо `mongodb` |
|
||||||
|
| «Kafka пользователь» | `nubes_kafka_user` ✅ | `postgres_manual` вместо `kafka` |
|
||||||
|
| «Node.js приложение» | `nubes_nodejs_redeploy` ⚠️ | `curated` вместо `nodejs` |
|
||||||
|
| «vDC организация» | `nubes_vc_org` (ожид. `nubes_vc_vdc`) ⚠️ | этап 1 выбрал org вместо vdc |
|
||||||
|
|
||||||
|
## Требуемые доработки (шаг 9 итерация)
|
||||||
|
|
||||||
|
1. **Буст страницам ресурсов** (первый сегмент не `_manual`, не `curated`/`30_registry`) — повысить score.
|
||||||
|
2. **Маппинг кириллица→латиница** для частых терминов (постгрес→postgres, бакет→bucket, диск→disk, машина→vm и т.п.).
|
||||||
|
3. **Подресурс → родитель**: `nubes_<база>_<подресурс>` при поиске дополнительно искать по токену `<база>`.
|
||||||
|
4. Пересборка скора: title бо́льший вес у страниц ресурсов.
|
||||||
|
|
||||||
|
После доработки — повторный прогон эталона.
|
||||||
@@ -15,17 +15,40 @@ const NUBES_RE = /nubes_[a-z0-9_]+/gi;
|
|||||||
|
|
||||||
/** Стоп-слова (русские + английские), отбрасываются при токенизации. */
|
/** Стоп-слова (русские + английские), отбрасываются при токенизации. */
|
||||||
const STOP_WORDS = new Set([
|
const STOP_WORDS = new Set([
|
||||||
// русские
|
// русские
|
||||||
"и", "в", "на", "с", "для", "как", "что", "это", "при", "по", "к", "из",
|
"и", "в", "на", "с", "для", "как", "что", "это", "при", "по", "к", "из",
|
||||||
"от", "не", "а", "но", "или", "чтобы", "когда", "где", "какой", "какие",
|
"от", "не", "а", "но", "или", "чтобы", "когда", "где", "какой", "какие",
|
||||||
// английские
|
"создать", "настроить", "развернуть", "поднять", "завести", "сделать",
|
||||||
"the", "a", "an", "and", "or", "for", "of", "to", "in", "on", "with",
|
"база", "базу", "базы", "пользователь", "пользователя",
|
||||||
"by", "is", "are", "how", "what", "which", "do", "does", "can",
|
// английские
|
||||||
|
"the", "a", "an", "and", "or", "for", "of", "to", "in", "on", "with",
|
||||||
|
"by", "is", "are", "how", "what", "which", "do", "does", "can",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Токен = последовательность латиница/кириллица/цифры/подчёркивание. */
|
/** Токен = последовательность латиница/кириллица/цифры/подчёркивание. */
|
||||||
const TOKEN_RE = /[a-zа-яё0-9_]+/giu;
|
const TOKEN_RE = /[a-zа-яё0-9_]+/giu;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Словарь транслитерации частых кириллических терминов → латиница,
|
||||||
|
* чтобы «постгрес» находил «postgres». Дополняется по необходимости.
|
||||||
|
*/
|
||||||
|
const TRANSLIT = {
|
||||||
|
"постгрес": "postgres",
|
||||||
|
"постгре": "postgres",
|
||||||
|
"бакет": "bucket",
|
||||||
|
"юзер": "user",
|
||||||
|
"машина": "vm",
|
||||||
|
"машину": "vm",
|
||||||
|
"машин": "vm",
|
||||||
|
"топик": "topic",
|
||||||
|
"кластер": "cluster",
|
||||||
|
"диск": "disk",
|
||||||
|
"сеть": "network",
|
||||||
|
"шлюз": "gateway",
|
||||||
|
"организация": "org",
|
||||||
|
"организацию": "org",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Убирает HTML-теги из текста, заменяя каждый тег на пробел
|
* Убирает HTML-теги из текста, заменяя каждый тег на пробел
|
||||||
* (чтобы соседние слова не склеивались). Не раскодирует HTML-сущности.
|
* (чтобы соседние слова не склеивались). Не раскодирует HTML-сущности.
|
||||||
@@ -34,8 +57,8 @@ const TOKEN_RE = /[a-zа-яё0-9_]+/giu;
|
|||||||
* @returns {string} - Текст без тегов.
|
* @returns {string} - Текст без тегов.
|
||||||
*/
|
*/
|
||||||
function stripHtml(text) {
|
function stripHtml(text) {
|
||||||
if (typeof text !== "string") return "";
|
if (typeof text !== "string") return "";
|
||||||
return text.replace(HTML_TAG_RE, " ");
|
return text.replace(HTML_TAG_RE, " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,16 +69,18 @@ function stripHtml(text) {
|
|||||||
* @returns {string[]} - Массив токенов в нижнем регистре.
|
* @returns {string[]} - Массив токенов в нижнем регистре.
|
||||||
*/
|
*/
|
||||||
function tokenize(text) {
|
function tokenize(text) {
|
||||||
if (typeof text !== "string") return [];
|
if (typeof text !== "string") return [];
|
||||||
const clean = stripHtml(text).toLowerCase();
|
const clean = stripHtml(text).toLowerCase();
|
||||||
const tokens = [];
|
const tokens = [];
|
||||||
const re = new RegExp(TOKEN_RE.source, "giu");
|
const re = new RegExp(TOKEN_RE.source, "giu");
|
||||||
let m;
|
let m;
|
||||||
while ((m = re.exec(clean)) !== null) {
|
while ((m = re.exec(clean)) !== null) {
|
||||||
const t = m[0];
|
let t = m[0];
|
||||||
if (!STOP_WORDS.has(t)) tokens.push(t);
|
if (STOP_WORDS.has(t)) continue;
|
||||||
}
|
if (TRANSLIT[t]) t = TRANSLIT[t]; // транслитерация кириллических терминов
|
||||||
return tokens;
|
tokens.push(t);
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,17 +90,17 @@ function tokenize(text) {
|
|||||||
* @returns {Set<string>} - Уникальные nubes_* имена в нижнем регистре.
|
* @returns {Set<string>} - Уникальные nubes_* имена в нижнем регистре.
|
||||||
*/
|
*/
|
||||||
function extractResourceTypes(index) {
|
function extractResourceTypes(index) {
|
||||||
const set = new Set();
|
const set = new Set();
|
||||||
if (!index || !Array.isArray(index.docs)) return set;
|
if (!index || !Array.isArray(index.docs)) return set;
|
||||||
for (const doc of index.docs) {
|
for (const doc of index.docs) {
|
||||||
if (!doc || typeof doc.text !== "string") continue;
|
if (!doc || typeof doc.text !== "string") continue;
|
||||||
const re = new RegExp(NUBES_RE.source, "gi");
|
const re = new RegExp(NUBES_RE.source, "gi");
|
||||||
let m;
|
let m;
|
||||||
while ((m = re.exec(doc.text)) !== null) {
|
while ((m = re.exec(doc.text)) !== null) {
|
||||||
set.add(m[0].toLowerCase());
|
set.add(m[0].toLowerCase());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return set;
|
||||||
return set;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,19 +112,19 @@ function extractResourceTypes(index) {
|
|||||||
* @returns {Set<string>} - Имена страниц ресурсов.
|
* @returns {Set<string>} - Имена страниц ресурсов.
|
||||||
*/
|
*/
|
||||||
function resourcePages(index) {
|
function resourcePages(index) {
|
||||||
const set = new Set();
|
const set = new Set();
|
||||||
if (!index || !Array.isArray(index.docs)) return set;
|
if (!index || !Array.isArray(index.docs)) return set;
|
||||||
for (const doc of index.docs) {
|
for (const doc of index.docs) {
|
||||||
const loc = doc && typeof doc.location === "string" ? doc.location : "";
|
const loc = doc && typeof doc.location === "string" ? doc.location : "";
|
||||||
if (!loc) continue;
|
if (!loc) continue;
|
||||||
const seg = loc.split("/")[0].trim();
|
const seg = loc.split("/")[0].trim();
|
||||||
if (!seg) continue;
|
if (!seg) continue;
|
||||||
if (seg.startsWith("#")) continue;
|
if (seg.startsWith("#")) continue;
|
||||||
if (seg.endsWith("_manual")) continue;
|
if (seg.endsWith("_manual")) continue;
|
||||||
if (seg === "curated" || seg === "30_registry") continue;
|
if (seg === "curated" || seg === "30_registry") continue;
|
||||||
set.add(seg);
|
set.add(seg);
|
||||||
}
|
}
|
||||||
return set;
|
return set;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,25 +135,70 @@ function resourcePages(index) {
|
|||||||
* @returns {{docs: object[], listResourceTypes: function, resourcePages: function}}
|
* @returns {{docs: object[], listResourceTypes: function, resourcePages: function}}
|
||||||
*/
|
*/
|
||||||
function loadIndex(rawJson) {
|
function loadIndex(rawJson) {
|
||||||
let index;
|
let index;
|
||||||
try {
|
try {
|
||||||
index = JSON.parse(rawJson);
|
index = JSON.parse(rawJson);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
index = { docs: [] };
|
index = { docs: [] };
|
||||||
}
|
}
|
||||||
if (!index || !Array.isArray(index.docs)) {
|
if (!index || !Array.isArray(index.docs)) {
|
||||||
index = { docs: Array.isArray(index) ? index : [] };
|
index = { docs: Array.isArray(index) ? index : [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
docs: Array.isArray(index.docs) ? index.docs : [],
|
docs: Array.isArray(index.docs) ? index.docs : [],
|
||||||
listResourceTypes() {
|
listResourceTypes() {
|
||||||
return extractResourceTypes({ docs: this.docs });
|
return extractResourceTypes({ docs: this.docs });
|
||||||
},
|
},
|
||||||
resourcePages() {
|
resourcePages() {
|
||||||
return resourcePages({ docs: this.docs });
|
return resourcePages({ docs: this.docs });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Классифицирует первый сегмент location:
|
||||||
|
* 'resource' — страница ресурса (гл. контент);
|
||||||
|
* 'manual' — руководство (_manual);
|
||||||
|
* 'service' — служебная (curated/30_registry);
|
||||||
|
* 'other' — прочее/якорь индексной страницы.
|
||||||
|
*
|
||||||
|
* @param {string} seg - первый сегмент location.
|
||||||
|
* @returns {'resource'|'manual'|'service'|'other'}
|
||||||
|
*/
|
||||||
|
function pageKind(seg) {
|
||||||
|
if (!seg || seg.startsWith("#")) return "other";
|
||||||
|
if (seg === "curated" || seg === "30_registry") return "service";
|
||||||
|
if (seg.endsWith("_manual")) return "manual";
|
||||||
|
return "resource";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Преобразует имя ресурса nubes_<база>_<подресурс> в базовое имя страницы
|
||||||
|
* (<база>). Для `nubes_postgres_database` → `postgres`; `nubes_postgres` → `postgres`.
|
||||||
|
*
|
||||||
|
* @param {string} resourceType - имя вида nubes_*.
|
||||||
|
* @returns {string|null} - базовый сегмент страницы или null.
|
||||||
|
*/
|
||||||
|
function resourceBaseSegment(resourceType) {
|
||||||
|
if (!resourceType || !resourceType.startsWith("nubes_")) return null;
|
||||||
|
const rest = resourceType.slice("nubes_".length);
|
||||||
|
if (!rest) return null;
|
||||||
|
// Известные подресурсы (суффиксы) — отрезаем до базового ресурса.
|
||||||
|
// Берём первую часть до суффикса-подресурса, если составное имя содержит известный подресурс.
|
||||||
|
const knownSubs = [
|
||||||
|
"_database", "_user", "_topic", "_backup", "_snat", "_record",
|
||||||
|
"_sub_user", "_ip_allocation", "_vdc", "_kv", "_policy", "_secrets",
|
||||||
|
"_team", "_redeploy", "_group",
|
||||||
|
];
|
||||||
|
let base = rest;
|
||||||
|
for (const sub of knownSubs) {
|
||||||
|
if (rest.endsWith(sub)) {
|
||||||
|
base = rest.slice(0, rest.length - sub.length);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,49 +210,60 @@ function loadIndex(rawJson) {
|
|||||||
* @returns {{location:string,title:string,text:string,score:number}[]}
|
* @returns {{location:string,title:string,text:string,score:number}[]}
|
||||||
*/
|
*/
|
||||||
function search(index, query, opts = {}) {
|
function search(index, query, 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;
|
||||||
const titleWeight = Number.isFinite(opts.titleWeight) ? opts.titleWeight : 3;
|
const titleWeight = Number.isFinite(opts.titleWeight) ? opts.titleWeight : 3;
|
||||||
|
|
||||||
const docs = index && Array.isArray(index.docs) ? index.docs : [];
|
const docs = index && Array.isArray(index.docs) ? index.docs : [];
|
||||||
const qTokens = tokenize(query);
|
const qTokens = tokenize(query);
|
||||||
if (qTokens.length === 0 || docs.length === 0) return [];
|
if (qTokens.length === 0 || docs.length === 0) return [];
|
||||||
|
|
||||||
const results = [];
|
// Взвешивание по типу страницы: ресурсы важнее руководств/служебных.
|
||||||
for (const doc of docs) {
|
const KIND_BOOST = { resource: 2.0, manual: 0.6, service: 0.4, other: 1.0 };
|
||||||
if (!doc) continue;
|
|
||||||
const titleTokens = tokenize(doc.title);
|
|
||||||
const textTokens = tokenize(doc.text);
|
|
||||||
|
|
||||||
let score = 0;
|
const results = [];
|
||||||
for (const t of qTokens) {
|
for (const doc of docs) {
|
||||||
let hits = 0;
|
if (!doc) continue;
|
||||||
for (const tt of titleTokens) if (tt === t) hits++;
|
const loc = typeof doc.location === "string" ? doc.location : "";
|
||||||
score += hits * titleWeight;
|
const seg = loc.split("/")[0].trim();
|
||||||
|
const boost = KIND_BOOST[pageKind(seg)] || 1.0;
|
||||||
|
|
||||||
hits = 0;
|
const titleTokens = tokenize(doc.title);
|
||||||
for (const tx of textTokens) if (tx === t) hits++;
|
const textTokens = tokenize(doc.text);
|
||||||
score += hits;
|
|
||||||
|
let score = 0;
|
||||||
|
for (const t of qTokens) {
|
||||||
|
let hits = 0;
|
||||||
|
for (const tt of titleTokens) if (tt === t) hits++;
|
||||||
|
score += hits * titleWeight;
|
||||||
|
|
||||||
|
hits = 0;
|
||||||
|
for (const tx of textTokens) if (tx === t) hits++;
|
||||||
|
score += hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
score *= boost;
|
||||||
|
|
||||||
|
if (score > 0) {
|
||||||
|
results.push({
|
||||||
|
location: loc,
|
||||||
|
title: typeof doc.title === "string" ? doc.title : "",
|
||||||
|
text: typeof doc.text === "string" ? doc.text : "",
|
||||||
|
score,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (score > 0) {
|
results.sort((a, b) => b.score - a.score || a.location.localeCompare(b.location));
|
||||||
results.push({
|
return results.slice(0, topK);
|
||||||
location: typeof doc.location === "string" ? doc.location : "",
|
|
||||||
title: typeof doc.title === "string" ? doc.title : "",
|
|
||||||
text: typeof doc.text === "string" ? doc.text : "",
|
|
||||||
score,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
results.sort((a, b) => b.score - a.score || a.location.localeCompare(b.location));
|
|
||||||
return results.slice(0, topK);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
loadIndex,
|
loadIndex,
|
||||||
tokenize,
|
tokenize,
|
||||||
search,
|
search,
|
||||||
stripHtml,
|
stripHtml,
|
||||||
extractResourceTypes,
|
extractResourceTypes,
|
||||||
resourcePages,
|
resourcePages,
|
||||||
|
pageKind,
|
||||||
|
resourceBaseSegment,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,17 +16,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const { llmChat } = require('./assist.js');
|
const { llmChat } = require('./assist.js');
|
||||||
const { search, stripHtml } = require('./search.js');
|
const { search, stripHtml, resourceBaseSegment } = require('./search.js');
|
||||||
|
|
||||||
const DEFAULT_MODEL = 'gpt-oss-120b';
|
const DEFAULT_MODEL = 'gpt-oss-120b';
|
||||||
|
|
||||||
// Заголовки, которые ОБЯЗАН содержать ответ этапа 2 (П.12 брифа).
|
// Заголовки, которые ОБЯЗАН содержать ответ этапа 2 (П.12 брифа).
|
||||||
const REQUIRED_HEADERS = [
|
const REQUIRED_HEADERS = [
|
||||||
'Роль',
|
'Роль',
|
||||||
'Задача',
|
'Задача',
|
||||||
'Контекст',
|
'Контекст',
|
||||||
'Требования',
|
'Требования',
|
||||||
'Ограничения',
|
'Ограничения',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,9 +37,9 @@ const REQUIRED_HEADERS = [
|
|||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
function isValidSourceUrl(url) {
|
function isValidSourceUrl(url) {
|
||||||
if (!url || typeof url !== 'string') return false;
|
if (!url || typeof url !== 'string') return false;
|
||||||
if (url.includes('//') || url.includes(':') || url.includes('..')) return false;
|
if (url.includes('//') || url.includes(':') || url.includes('..')) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,10 +50,10 @@ function isValidSourceUrl(url) {
|
|||||||
* @returns {string|null} - "/<stand>/<location>" или null, если недопустимо.
|
* @returns {string|null} - "/<stand>/<location>" или null, если недопустимо.
|
||||||
*/
|
*/
|
||||||
function sanitizeSourceUrl(stand, location) {
|
function sanitizeSourceUrl(stand, location) {
|
||||||
const clean = sanitizeLocation(location);
|
const clean = sanitizeLocation(location);
|
||||||
if (clean === null) return null;
|
if (clean === null) return null;
|
||||||
const url = `/${stand}/${clean}`;
|
const url = `/${stand}/${clean}`;
|
||||||
return isValidSourceUrl(url) ? url : null;
|
return isValidSourceUrl(url) ? url : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,10 +64,10 @@ function sanitizeSourceUrl(stand, location) {
|
|||||||
* @returns {string|null}
|
* @returns {string|null}
|
||||||
*/
|
*/
|
||||||
function sanitizeLocation(location) {
|
function sanitizeLocation(location) {
|
||||||
const s = String(location ?? '').trim();
|
const s = String(location ?? '').trim();
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
if (s.includes('..') || s.includes(':')) return null;
|
if (s.includes('..') || s.includes(':')) return null;
|
||||||
return s.replace(/^\/+|\/+$/g, '');
|
return s.replace(/^\/+|\/+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,53 +84,59 @@ function sanitizeLocation(location) {
|
|||||||
* @returns {Array<{location:string,title:string,text:string,score:number}>}
|
* @returns {Array<{location:string,title:string,text:string,score:number}>}
|
||||||
*/
|
*/
|
||||||
function mergeSearchResults(index, query, resourceType, opts = {}) {
|
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) поиск по токенам исходного запроса (всегда).
|
// 1) поиск по токенам исходного запроса (всегда).
|
||||||
const tokenResults = search(index, query, { topK: 50 });
|
const tokenResults = search(index, query, { topK: 50 });
|
||||||
|
|
||||||
// 2) поиск после этапа 1: если resource_type есть, ищем по его имени.
|
// 2) поиск после этапа 1: если resource_type есть, ищем по его имени.
|
||||||
let resourceResults = [];
|
let resourceResults = [];
|
||||||
if (resourceType) {
|
if (resourceType) {
|
||||||
resourceResults = search(index, resourceType, { topK: 50 });
|
// Ищем и по полному resource_type, и по базовому ресурсу (подресурс → родитель).
|
||||||
}
|
resourceResults = search(index, resourceType, { topK: 50 });
|
||||||
|
const base = resourceBaseSegment(resourceType);
|
||||||
// Объединяем по location, оценка = max.
|
if (base && base !== resourceType) {
|
||||||
const byLocation = new Map();
|
const baseResults = search(index, base, { topK: 50 });
|
||||||
const add = (arr) => {
|
resourceResults = resourceResults.concat(baseResults);
|
||||||
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);
|
|
||||||
|
|
||||||
// Собираем отсортированный массив.
|
// Объединяем по location, оценка = max.
|
||||||
const merged = [...byLocation.values()].sort(
|
const byLocation = new Map();
|
||||||
(a, b) => b.score - a.score || a.location.localeCompare(b.location)
|
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 = [];
|
const merged = [...byLocation.values()].sort(
|
||||||
if (resourceType) {
|
(a, b) => b.score - a.score || a.location.localeCompare(b.location)
|
||||||
const resourcePage = merged.find((r) => isResourcePage(r.location, resourceType));
|
);
|
||||||
if (resourcePage) {
|
|
||||||
result.push(resourcePage);
|
// Страница ресурса — обязательно первой (П.5), если resource_type найден.
|
||||||
|
const result = [];
|
||||||
|
if (resourceType) {
|
||||||
|
const resourcePage = merged.find((r) => isResourcePage(r.location, resourceType));
|
||||||
|
if (resourcePage) {
|
||||||
|
result.push(resourcePage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Дополняем до K без повторов.
|
// Дополняем до K без повторов.
|
||||||
for (const r of merged) {
|
for (const r of merged) {
|
||||||
if (result.length >= topK) break;
|
if (result.length >= topK) break;
|
||||||
if (result.includes(r)) continue;
|
if (result.includes(r)) continue;
|
||||||
result.push(r);
|
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}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
function isResourcePage(location, resourceType) {
|
function isResourcePage(location, resourceType) {
|
||||||
const pageName = resourceType.replace(/^nubes_/, '');
|
const pageName = resourceType.replace(/^nubes_/, '');
|
||||||
if (!pageName) return false;
|
if (!pageName) return false;
|
||||||
const seg = String(location || '').split('/')[0].trim();
|
const seg = String(location || '').split('/')[0].trim();
|
||||||
return seg === pageName;
|
return seg === pageName;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,11 +164,11 @@ function isResourcePage(location, resourceType) {
|
|||||||
* @returns {Array<{location:string,title:string,text:string}>}
|
* @returns {Array<{location:string,title:string,text:string}>}
|
||||||
*/
|
*/
|
||||||
function buildContext(entries, maxChars = 1500) {
|
function buildContext(entries, maxChars = 1500) {
|
||||||
return entries.map((e) => ({
|
return entries.map((e) => ({
|
||||||
location: e.location,
|
location: e.location,
|
||||||
title: e.title || '',
|
title: e.title || '',
|
||||||
text: stripHtml(e.text || '').slice(0, maxChars),
|
text: stripHtml(e.text || '').slice(0, maxChars),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,26 +179,26 @@ function buildContext(entries, maxChars = 1500) {
|
|||||||
* @returns {Array<{title:string, url:string}>}
|
* @returns {Array<{title:string, url:string}>}
|
||||||
*/
|
*/
|
||||||
function collectSources(entries, stand) {
|
function collectSources(entries, stand) {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
const url = sanitizeSourceUrl(stand, e.location);
|
const url = sanitizeSourceUrl(stand, e.location);
|
||||||
if (url) {
|
if (url) {
|
||||||
out.push({ title: stripHtml(e.title || ''), url });
|
out.push({ title: stripHtml(e.title || ''), url });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return out;
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Системный промпт этапа 2: строгий шаблон с обязательными заголовками.
|
* Системный промпт этапа 2: строгий шаблон с обязательными заголовками.
|
||||||
*/
|
*/
|
||||||
const STAGE2_SYSTEM_PROMPT =
|
const STAGE2_SYSTEM_PROMPT =
|
||||||
'Ты — ассистент документации Terraform-провайдера Nubes. ' +
|
'Ты — ассистент документации Terraform-провайдера Nubes. ' +
|
||||||
'Составь ответ СТРОГО по шаблону с заголовками-маркерами, каждый на новой строке:\n' +
|
'Составь ответ СТРОГО по шаблону с заголовками-маркерами, каждый на новой строке:\n' +
|
||||||
'Роль:\nЗадача:\nКонтекст:\nТребования:\nОграничения:\n' +
|
'Роль:\nЗадача:\nКонтекст:\nТребования:\nОграничения:\n' +
|
||||||
'Используй ТОЛЬКО факты из блока ДОКУМЕНТАЦИЯ ниже. ' +
|
'Используй ТОЛЬКО факты из блока ДОКУМЕНТАЦИЯ ниже. ' +
|
||||||
'Не выдумывай ресурсов, атрибутов и значений, которых нет в документации. ' +
|
'Не выдумывай ресурсов, атрибутов и значений, которых нет в документации. ' +
|
||||||
'Отвечай на русском языке.';
|
'Отвечай на русском языке.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Этап 2: генерация итогового ответа по строгому шаблону.
|
* Этап 2: генерация итогового ответа по строгому шаблону.
|
||||||
@@ -208,61 +214,61 @@ const STAGE2_SYSTEM_PROMPT =
|
|||||||
* @returns {Promise<{ok:true, answer:string} | {ok:false, error:string}>}
|
* @returns {Promise<{ok:true, answer:string} | {ok:false, error:string}>}
|
||||||
*/
|
*/
|
||||||
async function stage2Generate({
|
async function stage2Generate({
|
||||||
question,
|
question,
|
||||||
context,
|
context,
|
||||||
baseUrl = process.env.LLM_BASE_URL || '',
|
baseUrl = process.env.LLM_BASE_URL || '',
|
||||||
apiKey = process.env.LLM_API_KEY || '',
|
apiKey = process.env.LLM_API_KEY || '',
|
||||||
model = process.env.LLM_MODEL || DEFAULT_MODEL,
|
model = process.env.LLM_MODEL || DEFAULT_MODEL,
|
||||||
maxTokens = 1024,
|
maxTokens = 1024,
|
||||||
controller,
|
controller,
|
||||||
}) {
|
}) {
|
||||||
const docsBlock = context
|
const docsBlock = context
|
||||||
.map((c, i) => `[${i + 1}] ${c.title}\n${c.text}`)
|
.map((c, i) => `[${i + 1}] ${c.title}\n${c.text}`)
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
|
|
||||||
const userContent =
|
const userContent =
|
||||||
'ДОКУМЕНТАЦИЯ:\n' +
|
'ДОКУМЕНТАЦИЯ:\n' +
|
||||||
(docsBlock || '(нет релевантных фрагментов)') +
|
(docsBlock || '(нет релевантных фрагментов)') +
|
||||||
'\n\nВОПРОС ПОЛЬЗОВАТЕЛЯ:\n' +
|
'\n\nВОПРОС ПОЛЬЗОВАТЕЛЯ:\n' +
|
||||||
String(question ?? '');
|
String(question ?? '');
|
||||||
|
|
||||||
const messages = [
|
const messages = [
|
||||||
{ role: 'system', content: STAGE2_SYSTEM_PROMPT },
|
{ role: 'system', content: STAGE2_SYSTEM_PROMPT },
|
||||||
{ role: 'user', content: userContent },
|
{ role: 'user', content: userContent },
|
||||||
];
|
];
|
||||||
|
|
||||||
let content;
|
let content;
|
||||||
try {
|
try {
|
||||||
content = await llmChat({
|
content = await llmChat({
|
||||||
messages,
|
messages,
|
||||||
maxTokens,
|
maxTokens,
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
controller,
|
controller,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
model,
|
model,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, error: err && err.code ? err.code : 'stage2_failed' };
|
return { ok: false, error: err && err.code ? err.code : 'stage2_failed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем наличие обязательных заголовков (П.12).
|
// Проверяем наличие обязательных заголовков (П.12).
|
||||||
const missing = REQUIRED_HEADERS.filter((h) => !content.includes(h + ':'));
|
const missing = REQUIRED_HEADERS.filter((h) => !content.includes(h + ':'));
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
return { ok: false, error: 'bad_llm_output' };
|
return { ok: false, error: 'bad_llm_output' };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true, answer: content };
|
return { ok: true, answer: content };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
mergeSearchResults,
|
mergeSearchResults,
|
||||||
buildContext,
|
buildContext,
|
||||||
collectSources,
|
collectSources,
|
||||||
sanitizeSourceUrl,
|
sanitizeSourceUrl,
|
||||||
sanitizeLocation,
|
sanitizeLocation,
|
||||||
isValidSourceUrl,
|
isValidSourceUrl,
|
||||||
isResourcePage,
|
isResourcePage,
|
||||||
stage2Generate,
|
stage2Generate,
|
||||||
REQUIRED_HEADERS,
|
REQUIRED_HEADERS,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user