"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; /** * Убирает 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) { const t = m[0]; if (!STOP_WORDS.has(t)) tokens.push(t); } return tokens; } /** * Извлекает Set уникальных имён ресурсов вида nubes_* из текста всех записей индекса. * * @param {object} index - Распарсенный индекс (как из loadIndex). * @returns {Set} - Уникальные 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} - Имена страниц ресурсов. */ 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 : [] }; } return { docs: Array.isArray(index.docs) ? index.docs : [], listResourceTypes() { return extractResourceTypes({ docs: this.docs }); }, resourcePages() { return resourcePages({ docs: this.docs }); }, }; } /** * 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 []; const results = []; for (const doc of docs) { if (!doc) continue; const titleTokens = tokenize(doc.title); const 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; } if (score > 0) { results.push({ 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 = { loadIndex, tokenize, search, stripHtml, extractResourceTypes, resourcePages, };