Files
tf_docs/search.js
T
Repinoid 12d29ee98d fix: поиск по подресурсам + реальный per-call таймаут ЛЛМ
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.
2026-10-12 07:51:48 +03:00

299 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
};