From 827870d51c6f37628c1b6395f6715d0c5c80f288 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Wed, 7 Oct 2026 13:46:19 +0300 Subject: [PATCH] =?UTF-8?q?feat(search):=20=D0=BB=D0=B5=D0=BA=D1=81=D0=B8?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20=D0=BF=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=20=D0=BF=D0=BE=20search=5Findex.json=20+=20=D1=8D=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BD=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=20(28=20=D0=B2=D0=BE=D0=BF=D1=80=D0=BE=D1=81=D0=BE?= =?UTF-8?q?=D0=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_project/golden/golden_questions.json | 30 ++++ search.js | 188 +++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 llm_project/golden/golden_questions.json create mode 100644 search.js diff --git a/llm_project/golden/golden_questions.json b/llm_project/golden/golden_questions.json new file mode 100644 index 0000000..40e6cf3 --- /dev/null +++ b/llm_project/golden/golden_questions.json @@ -0,0 +1,30 @@ +[ + {"question": "Как создать виртуальную машину в облаке Nubes?", "expected_pages": ["vc_vm_v3"], "resource_type": "nubes_vc_vm_v3"}, + {"question": "Как настроить PostgreSQL с автоматическим бэкапом?", "expected_pages": ["postgres"], "resource_type": "nubes_postgres"}, + {"question": "Как создать базу данных и пользователя в PostgreSQL?", "expected_pages": ["postgres"], "resource_type": "nubes_postgres_database"}, + {"question": "Как выдать виртуальной машине белый IP-адрес?", "expected_pages": ["vcexternalip"], "resource_type": "nubes_vcexternalip"}, + {"question": "Как поднять MongoDB с ежедневным бэкапом?", "expected_pages": ["mongodb"], "resource_type": "nubes_mongodb"}, + {"question": "Как завести пользователя и пароль для MongoDB?", "expected_pages": ["mongodb"], "resource_type": "nubes_mongodb_user"}, + {"question": "Как развернуть Redis для кэширования?", "expected_pages": ["redis"], "resource_type": "nubes_redis"}, + {"question": "Как создать MariaDB и привязать к ней отдельную базу?", "expected_pages": ["mariadb"], "resource_type": "nubes_mariadb"}, + {"question": "Как поднять ClickHouse и создать в нём таблицу?", "expected_pages": ["clickhouse"], "resource_type": "nubes_clickhouse_database"}, + {"question": "Как развернуть Kafka-кластер и создать топик?", "expected_pages": ["kafka"], "resource_type": "nubes_kafka_topic"}, + {"question": "Как завести пользователя Kafka с правами на конкретный топик?", "expected_pages": ["kafka"], "resource_type": "nubes_kafka_user"}, + {"question": "Как развернуть RabbitMQ для очередей задач?", "expected_pages": ["rabbitmq"], "resource_type": "nubes_rabbitmq"}, + {"question": "Как создать S3-бакет для хранения файлов?", "expected_pages": ["s3bucket", "s3"], "resource_type": "nubes_s3bucket"}, + {"question": "Как создать S3-хранилище и сублюзеров с ограниченными ключами?", "expected_pages": ["s3"], "resource_type": "nubes_s3_sub_user"}, + {"question": "Как развернуть Kubernetes-кластер в Shturval?", "expected_pages": ["k8s_sthutrval_cluster"], "resource_type": "nubes_k8s_sthutrval_cluster"}, + {"question": "Как установить Velero для бэкапа Kubernetes?", "expected_pages": ["k8s_velero"], "resource_type": "nubes_k8s_velero"}, + {"question": "Как развернуть OpenBao для хранения секретов в кластере?", "expected_pages": ["k8s_openbao"], "resource_type": "nubes_k8s_openbao"}, + {"question": "Как создать DNS-запись для своего домена?", "expected_pages": ["dnsrecord", "zones_v2"], "resource_type": "nubes_dnsrecord"}, + {"question": "Как создать организацию и vDC в VMware-облаке?", "expected_pages": ["vc_org", "vc_vdc"], "resource_type": "nubes_vc_vdc"}, + {"question": "Как настроить SNAT в NSX-T?", "expected_pages": ["vc_nsxt"], "resource_type": "nubes_vc_nsxt_snat"}, + {"question": "Как развернуть LLM-модель с секретами и командой?", "expected_pages": ["llm_ai"], "resource_type": "nubes_llm_ai"}, + {"question": "Как развернуть Nextcloud и настроить бэкап?", "expected_pages": ["nextcloud"], "resource_type": "nubes_nextcloud"}, + {"question": "Как развернуть Node.js приложение и сделать передеплой?", "expected_pages": ["nodejs"], "resource_type": "nubes_nodejs"}, + {"question": "Как задеплоить Flask-приложение и получить его URL?", "expected_pages": ["flask"], "resource_type": "nubes_flask"}, + {"question": "Как развернуть pgAdmin для управления базами?", "expected_pages": ["pgadmin"], "resource_type": "nubes_pgadmin"}, + {"question": "Как подключиться к API Terraform-провайдера Nubes?", "expected_pages": [], "resource_type": ""}, + {"question": "Какие версии провайдера сейчас доступны и как их обновлять?", "expected_pages": ["template"], "resource_type": ""}, + {"question": "Как создать nubes_instance с плавающим IP floating_ip?", "expected_pages": [], "resource_type": ""} +] diff --git a/search.js b/search.js new file mode 100644 index 0000000..269a81b --- /dev/null +++ b/search.js @@ -0,0 +1,188 @@ +"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, +};