search(resourceBaseSegment): суффиксы-подресурсы перебираются по убыванию длины (KNOWN_RESOURCE_SUBS). Иначе "_user" матчился раньше "_sub_user" и nubes_s3_sub_user давал "s3_sub" вместо "s3" — страница ресурса не находилась. assist(llmChat): реально применяет ASSIST_LLM_TIMEOUT_MS — таймаут ОДНОГО вызова (покрывает и чтение тела ответа). Таймаут возвращает отдельный код llm_timeout, отмена вызывающим (дедлайн/закрытие соединения) — по-прежнему aborted, чтобы stage2 различал причины. stage1: убран дублирующий AbortController (таймаут теперь только в llmChat). orchestrator: llm_timeout -> HTTP 504.
299 lines
12 KiB
JavaScript
299 lines
12 KiB
JavaScript
"use strict";
|
||
|
||
/**
|
||
* Модуль лексического (BM25-подобного) поиска по search_index.json
|
||
* для AI-ассистента документации Nubes.
|
||
*
|
||
* Node.js >= 18, без внешних зависимостей.
|
||
* Экспорты: loadIndex, tokenize, search, stripHtml, extractResourceTypes.
|
||
*/
|
||
|
||
const HTML_TAG_RE = /<[^>]+>/g;
|
||
|
||
/** Имена nubes_* ресурсов: nubes_postgres, nubes_vc_vdc, nubes_s3_bucket и т.п. */
|
||
const NUBES_RE = /nubes_[a-z0-9_]+/gi;
|
||
|
||
/** Стоп-слова (русские + английские), отбрасываются при токенизации. */
|
||
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",
|
||
]);
|
||
|
||
/** Токен = последовательность латиница/кириллица/цифры/подчёркивание. */
|
||
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-сущности.
|
||
*
|
||
* @param {string} text - Исходный текст, возможно с HTML-тегами.
|
||
* @returns {string} - Текст без тегов.
|
||
*/
|
||
function stripHtml(text) {
|
||
if (typeof text !== "string") return "";
|
||
return text.replace(HTML_TAG_RE, " ");
|
||
}
|
||
|
||
/**
|
||
* Токенизация: lowercase, удаление HTML-тегов, разбиение на токены
|
||
* (латиница/кириллица/цифры/подчёркивание), отбрасывание стоп-слов.
|
||
*
|
||
* @param {string} text - Исходный текст.
|
||
* @returns {string[]} - Массив токенов в нижнем регистре.
|
||
*/
|
||
function tokenize(text) {
|
||
if (typeof text !== "string") return [];
|
||
const clean = stripHtml(text).toLowerCase();
|
||
const tokens = [];
|
||
const re = new RegExp(TOKEN_RE.source, "giu");
|
||
let m;
|
||
while ((m = re.exec(clean)) !== null) {
|
||
let t = m[0];
|
||
if (STOP_WORDS.has(t)) continue;
|
||
if (TRANSLIT[t]) t = TRANSLIT[t]; // транслитерация кириллических терминов
|
||
tokens.push(t);
|
||
}
|
||
return tokens;
|
||
}
|
||
|
||
/**
|
||
* Извлекает Set уникальных имён ресурсов вида nubes_* из текста всех записей индекса.
|
||
*
|
||
* @param {object} index - Распарсенный индекс (как из loadIndex).
|
||
* @returns {Set<string>} - Уникальные nubes_* имена в нижнем регистре.
|
||
*/
|
||
function extractResourceTypes(index) {
|
||
const set = new Set();
|
||
if (!index || !Array.isArray(index.docs)) return set;
|
||
for (const doc of index.docs) {
|
||
if (!doc || typeof doc.text !== "string") continue;
|
||
const re = new RegExp(NUBES_RE.source, "gi");
|
||
let m;
|
||
while ((m = re.exec(doc.text)) !== null) {
|
||
set.add(m[0].toLowerCase());
|
||
}
|
||
}
|
||
return set;
|
||
}
|
||
|
||
/**
|
||
* Set имён страниц ресурсов: первый сегмент location (до "/").
|
||
* Исключаются: пустой сегмент, якоря (начинающиеся с "#"),
|
||
* страницы-руководства (оканчиваются на "_manual") и служебные curated/30_registry.
|
||
*
|
||
* @param {object} index - Распарсенный индекс.
|
||
* @returns {Set<string>} - Имена страниц ресурсов.
|
||
*/
|
||
function resourcePages(index) {
|
||
const set = new Set();
|
||
if (!index || !Array.isArray(index.docs)) return set;
|
||
for (const doc of index.docs) {
|
||
const loc = doc && typeof doc.location === "string" ? doc.location : "";
|
||
if (!loc) continue;
|
||
const seg = loc.split("/")[0].trim();
|
||
if (!seg) continue;
|
||
if (seg.startsWith("#")) continue;
|
||
if (seg.endsWith("_manual")) continue;
|
||
if (seg === "curated" || seg === "30_registry") continue;
|
||
set.add(seg);
|
||
}
|
||
return set;
|
||
}
|
||
|
||
/**
|
||
* Парсит строку JSON индекса MkDocs (search_index.json) и возвращает объект
|
||
* с исходными записями и утилитами по ресурсам.
|
||
*
|
||
* @param {string} rawJson - Строка JSON индекса.
|
||
* @returns {{docs: object[], listResourceTypes: function, resourcePages: function}}
|
||
*/
|
||
function loadIndex(rawJson) {
|
||
let index;
|
||
try {
|
||
index = JSON.parse(rawJson);
|
||
} catch (e) {
|
||
index = { docs: [] };
|
||
}
|
||
if (!index || !Array.isArray(index.docs)) {
|
||
index = { docs: Array.isArray(index) ? index : [] };
|
||
}
|
||
|
||
const docs = (Array.isArray(index.docs) ? index.docs : []).map((doc) => ({
|
||
location: typeof doc.location === 'string' ? doc.location : '',
|
||
title: typeof doc.title === 'string' ? doc.title : '',
|
||
text: typeof doc.text === 'string' ? doc.text : '',
|
||
// Предрасчёт: токены и сегмент считаются ОДИН раз при загрузке,
|
||
// а не на каждый поисковый запрос (экономия ~1 с на 823 доках).
|
||
_seg: (typeof doc.location === 'string' ? doc.location : '').split('/')[0].trim(),
|
||
_titleTokens: tokenize(doc.title),
|
||
_textTokens: tokenize(doc.text),
|
||
}));
|
||
|
||
// Предрасчёт: списки ресурсов считаются ОДИН раз при загрузке индекса,
|
||
// а не на каждый запрос (экономия CPU на regex по всем докам).
|
||
const resourceTypes = extractResourceTypes({ docs });
|
||
const pages = resourcePages({ docs });
|
||
|
||
return {
|
||
docs,
|
||
listResourceTypes() {
|
||
return resourceTypes;
|
||
},
|
||
resourcePages() {
|
||
return pages;
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Классифицирует первый сегмент 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") return "guide";
|
||
if (seg === "30_registry") return "service";
|
||
if (seg.endsWith("_manual")) return "manual";
|
||
return "resource";
|
||
}
|
||
|
||
/**
|
||
* Суффиксы-подресурсы в именах `nubes_*`, по которым отрезается хвост до
|
||
* базового ресурса (например, `nubes_postgres_database` → `postgres`).
|
||
*
|
||
* ⚠️ Порядок перебора критичен: суффиксы идут по УБЫВАНИЮ длины, поэтому
|
||
* сортируем массив один раз при загрузке модуля. Иначе короткий `_user`
|
||
* затеняет более длинный `_sub_user`: для `nubes_s3_sub_user` (rest =
|
||
* "s3_sub_user") отрезался бы `_user`, и получалось "s3_sub" вместо "s3",
|
||
* из-за чего страница ресурса не находилась (`isResourcePage`).
|
||
*/
|
||
const KNOWN_RESOURCE_SUBS = [
|
||
"_database", "_user", "_topic", "_backup", "_snat", "_record",
|
||
"_sub_user", "_ip_allocation", "_vdc", "_kv", "_policy", "_secrets",
|
||
"_team", "_redeploy", "_group",
|
||
].sort((a, b) => b.length - a.length);
|
||
|
||
/**
|
||
* Преобразует имя ресурса 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;
|
||
// Известные суффиксы-подресурсы; перебор идёт от самого длинного к самому
|
||
// короткому (порядок задан в KNOWN_RESOURCE_SUBS, см. определение выше).
|
||
let base = rest;
|
||
for (const sub of KNOWN_RESOURCE_SUBS) {
|
||
if (rest.endsWith(sub)) {
|
||
base = rest.slice(0, rest.length - sub.length);
|
||
break;
|
||
}
|
||
}
|
||
return base || null;
|
||
}
|
||
|
||
/**
|
||
* BM25-подобный лексический поиск по индексу.
|
||
*
|
||
* @param {object} index - Объект, возвращённый loadIndex (или {docs: [...]}).
|
||
* @param {string} query - Поисковый запрос.
|
||
* @param {{topK?: number, titleWeight?: number}} [opts]
|
||
* @returns {{location:string,title:string,text:string,score:number}[]}
|
||
*/
|
||
function search(index, query, opts = {}) {
|
||
const topK = Number.isFinite(opts.topK) && opts.topK > 0 ? Math.floor(opts.topK) : 5;
|
||
const titleWeight = Number.isFinite(opts.titleWeight) ? opts.titleWeight : 3;
|
||
|
||
const docs = index && Array.isArray(index.docs) ? index.docs : [];
|
||
const qTokens = tokenize(query);
|
||
if (qTokens.length === 0 || docs.length === 0) return [];
|
||
|
||
// Взвешивание по типу страницы: готовые примеры (curated) важнее всего,
|
||
// затем страницы ресурсов, затем руководства и служебные.
|
||
const KIND_BOOST = { guide: 2.4, resource: 2.0, manual: 0.6, service: 0.4, other: 1.0 };
|
||
|
||
const results = [];
|
||
for (const doc of docs) {
|
||
if (!doc) continue;
|
||
const loc = typeof doc.location === "string" ? doc.location : "";
|
||
const seg = doc._seg !== undefined ? doc._seg : loc.split("/")[0].trim();
|
||
const boost = KIND_BOOST[pageKind(seg)] || 1.0;
|
||
|
||
const titleTokens = doc._titleTokens || tokenize(doc.title);
|
||
const textTokens = doc._textTokens || tokenize(doc.text);
|
||
|
||
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,
|
||
});
|
||
}
|
||
}
|
||
|
||
results.sort((a, b) => b.score - a.score || a.location.localeCompare(b.location));
|
||
return results.slice(0, topK);
|
||
}
|
||
|
||
module.exports = {
|
||
loadIndex,
|
||
tokenize,
|
||
search,
|
||
stripHtml,
|
||
extractResourceTypes,
|
||
resourcePages,
|
||
pageKind,
|
||
resourceBaseSegment,
|
||
};
|