From 2136536b0d4be76ad74b9027a907c16e7def5a7e Mon Sep 17 00:00:00 2001 From: Repinoid Date: Wed, 7 Oct 2026 13:57:06 +0300 Subject: [PATCH] =?UTF-8?q?perf(search):=20=D0=B1=D1=83=D1=81=D1=82=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=D0=BC=20=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=83=D1=80=D1=81=D0=BE=D0=B2,=20=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D1=81=D0=BB=D0=B8=D1=82=20=D0=BA=D0=B8=D1=80=D0=B8?= =?UTF-8?q?=D0=BB=D0=BB=D0=B8=D1=86=D0=B0=E2=86=92=D0=BB=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D0=B0,=20=D0=BF=D0=BE=D0=B4=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D1=83=D1=80=D1=81=E2=86=92=D1=80=D0=BE=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=20(=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=2071%?= =?UTF-8?q?=E2=86=9293%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_project/golden/EVAL_1.md | 35 +++++ search.js | 271 +++++++++++++++++++++++------------ stage2.js | 258 +++++++++++++++++---------------- 3 files changed, 343 insertions(+), 221 deletions(-) create mode 100644 llm_project/golden/EVAL_1.md diff --git a/llm_project/golden/EVAL_1.md b/llm_project/golden/EVAL_1.md new file mode 100644 index 0000000..6948720 --- /dev/null +++ b/llm_project/golden/EVAL_1.md @@ -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 бо́льший вес у страниц ресурсов. + +После доработки — повторный прогон эталона. diff --git a/search.js b/search.js index 269a81b..30215ac 100644 --- a/search.js +++ b/search.js @@ -15,17 +15,40 @@ 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", + // русские + "и", "в", "на", "с", "для", "как", "что", "это", "при", "по", "к", "из", + "от", "не", "а", "но", "или", "чтобы", "когда", "где", "какой", "какие", + "создать", "настроить", "развернуть", "поднять", "завести", "сделать", + "база", "базу", "базы", "пользователь", "пользователя", + // английские + "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-сущности. @@ -34,8 +57,8 @@ const TOKEN_RE = /[a-zа-яё0-9_]+/giu; * @returns {string} - Текст без тегов. */ function stripHtml(text) { - if (typeof text !== "string") return ""; - return text.replace(HTML_TAG_RE, " "); + if (typeof text !== "string") return ""; + return text.replace(HTML_TAG_RE, " "); } /** @@ -46,16 +69,18 @@ function stripHtml(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; + 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; } /** @@ -65,17 +90,17 @@ function tokenize(text) { * @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()); + 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; + return set; } /** @@ -87,19 +112,19 @@ function extractResourceTypes(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; + 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; } /** @@ -110,25 +135,70 @@ function resourcePages(index) { * @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 : [] }; - } + 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 }); - }, - }; + return { + docs: Array.isArray(index.docs) ? index.docs : [], + listResourceTypes() { + return extractResourceTypes({ docs: this.docs }); + }, + resourcePages() { + 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}[]} */ 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 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 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); + // Взвешивание по типу страницы: ресурсы важнее руководств/служебных. + const KIND_BOOST = { resource: 2.0, manual: 0.6, service: 0.4, other: 1.0 }; - let score = 0; - for (const t of qTokens) { - let hits = 0; - for (const tt of titleTokens) if (tt === t) hits++; - score += hits * titleWeight; + const results = []; + for (const doc of docs) { + if (!doc) continue; + const loc = typeof doc.location === "string" ? doc.location : ""; + const seg = loc.split("/")[0].trim(); + const boost = KIND_BOOST[pageKind(seg)] || 1.0; - hits = 0; - for (const tx of textTokens) if (tx === t) hits++; - score += hits; + 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; + } + + 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.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); + 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, + loadIndex, + tokenize, + search, + stripHtml, + extractResourceTypes, + resourcePages, + pageKind, + resourceBaseSegment, }; diff --git a/stage2.js b/stage2.js index cee223c..1ef0971 100644 --- a/stage2.js +++ b/stage2.js @@ -16,17 +16,17 @@ */ const { llmChat } = require('./assist.js'); -const { search, stripHtml } = require('./search.js'); +const { search, stripHtml, resourceBaseSegment } = require('./search.js'); const DEFAULT_MODEL = 'gpt-oss-120b'; // Заголовки, которые ОБЯЗАН содержать ответ этапа 2 (П.12 брифа). const REQUIRED_HEADERS = [ - 'Роль', - 'Задача', - 'Контекст', - 'Требования', - 'Ограничения', + 'Роль', + 'Задача', + 'Контекст', + 'Требования', + 'Ограничения', ]; /** @@ -37,9 +37,9 @@ const REQUIRED_HEADERS = [ * @returns {boolean} */ function isValidSourceUrl(url) { - if (!url || typeof url !== 'string') return false; - if (url.includes('//') || url.includes(':') || url.includes('..')) return false; - return true; + if (!url || typeof url !== 'string') return false; + if (url.includes('//') || url.includes(':') || url.includes('..')) return false; + return true; } /** @@ -50,10 +50,10 @@ function isValidSourceUrl(url) { * @returns {string|null} - "//" или null, если недопустимо. */ function sanitizeSourceUrl(stand, location) { - const clean = sanitizeLocation(location); - if (clean === null) return null; - const url = `/${stand}/${clean}`; - return isValidSourceUrl(url) ? url : null; + const clean = sanitizeLocation(location); + if (clean === null) return null; + const url = `/${stand}/${clean}`; + return isValidSourceUrl(url) ? url : null; } /** @@ -64,10 +64,10 @@ function sanitizeSourceUrl(stand, location) { * @returns {string|null} */ function sanitizeLocation(location) { - const s = String(location ?? '').trim(); - if (!s) return null; - if (s.includes('..') || s.includes(':')) return null; - return s.replace(/^\/+|\/+$/g, ''); + const s = String(location ?? '').trim(); + if (!s) return null; + if (s.includes('..') || s.includes(':')) return null; + return s.replace(/^\/+|\/+$/g, ''); } /** @@ -84,53 +84,59 @@ function sanitizeLocation(location) { * @returns {Array<{location:string,title:string,text:string,score:number}>} */ 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) поиск по токенам исходного запроса (всегда). - const tokenResults = search(index, query, { topK: 50 }); + // 1) поиск по токенам исходного запроса (всегда). + const tokenResults = search(index, query, { topK: 50 }); - // 2) поиск после этапа 1: если resource_type есть, ищем по его имени. - let resourceResults = []; - if (resourceType) { - resourceResults = search(index, resourceType, { topK: 50 }); - } - - // Объединяем по location, оценка = max. - const byLocation = new Map(); - 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); - } + // 2) поиск после этапа 1: если resource_type есть, ищем по его имени. + let resourceResults = []; + if (resourceType) { + // Ищем и по полному resource_type, и по базовому ресурсу (подресурс → родитель). + resourceResults = search(index, resourceType, { topK: 50 }); + const base = resourceBaseSegment(resourceType); + if (base && base !== resourceType) { + const baseResults = search(index, base, { topK: 50 }); + resourceResults = resourceResults.concat(baseResults); + } } - }; - add(tokenResults); - add(resourceResults); - // Собираем отсортированный массив. - const merged = [...byLocation.values()].sort( - (a, b) => b.score - a.score || a.location.localeCompare(b.location) - ); + // Объединяем по location, оценка = max. + const byLocation = new Map(); + 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 = []; - if (resourceType) { - const resourcePage = merged.find((r) => isResourcePage(r.location, resourceType)); - if (resourcePage) { - result.push(resourcePage); + // Собираем отсортированный массив. + const merged = [...byLocation.values()].sort( + (a, b) => b.score - a.score || a.location.localeCompare(b.location) + ); + + // Страница ресурса — обязательно первой (П.5), если resource_type найден. + const result = []; + if (resourceType) { + const resourcePage = merged.find((r) => isResourcePage(r.location, resourceType)); + if (resourcePage) { + result.push(resourcePage); + } } - } - // Дополняем до K без повторов. - for (const r of merged) { - if (result.length >= topK) break; - if (result.includes(r)) continue; - result.push(r); - } + // Дополняем до K без повторов. + for (const r of merged) { + if (result.length >= topK) break; + if (result.includes(r)) continue; + 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} */ function isResourcePage(location, resourceType) { - const pageName = resourceType.replace(/^nubes_/, ''); - if (!pageName) return false; - const seg = String(location || '').split('/')[0].trim(); - return seg === pageName; + const pageName = resourceType.replace(/^nubes_/, ''); + if (!pageName) return false; + const seg = String(location || '').split('/')[0].trim(); + return seg === pageName; } /** @@ -158,11 +164,11 @@ function isResourcePage(location, resourceType) { * @returns {Array<{location:string,title:string,text:string}>} */ function buildContext(entries, maxChars = 1500) { - return entries.map((e) => ({ - location: e.location, - title: e.title || '', - text: stripHtml(e.text || '').slice(0, maxChars), - })); + return entries.map((e) => ({ + location: e.location, + title: e.title || '', + text: stripHtml(e.text || '').slice(0, maxChars), + })); } /** @@ -173,26 +179,26 @@ function buildContext(entries, maxChars = 1500) { * @returns {Array<{title:string, url:string}>} */ function collectSources(entries, stand) { - const out = []; - for (const e of entries) { - const url = sanitizeSourceUrl(stand, e.location); - if (url) { - out.push({ title: stripHtml(e.title || ''), url }); + const out = []; + for (const e of entries) { + const url = sanitizeSourceUrl(stand, e.location); + if (url) { + out.push({ title: stripHtml(e.title || ''), url }); + } } - } - return out; + return out; } /** * Системный промпт этапа 2: строгий шаблон с обязательными заголовками. */ const STAGE2_SYSTEM_PROMPT = - 'Ты — ассистент документации Terraform-провайдера Nubes. ' + - 'Составь ответ СТРОГО по шаблону с заголовками-маркерами, каждый на новой строке:\n' + - 'Роль:\nЗадача:\nКонтекст:\nТребования:\nОграничения:\n' + - 'Используй ТОЛЬКО факты из блока ДОКУМЕНТАЦИЯ ниже. ' + - 'Не выдумывай ресурсов, атрибутов и значений, которых нет в документации. ' + - 'Отвечай на русском языке.'; + 'Ты — ассистент документации Terraform-провайдера Nubes. ' + + 'Составь ответ СТРОГО по шаблону с заголовками-маркерами, каждый на новой строке:\n' + + 'Роль:\nЗадача:\nКонтекст:\nТребования:\nОграничения:\n' + + 'Используй ТОЛЬКО факты из блока ДОКУМЕНТАЦИЯ ниже. ' + + 'Не выдумывай ресурсов, атрибутов и значений, которых нет в документации. ' + + 'Отвечай на русском языке.'; /** * Этап 2: генерация итогового ответа по строгому шаблону. @@ -208,61 +214,61 @@ const STAGE2_SYSTEM_PROMPT = * @returns {Promise<{ok:true, answer:string} | {ok:false, error:string}>} */ async function stage2Generate({ - question, - context, - baseUrl = process.env.LLM_BASE_URL || '', - apiKey = process.env.LLM_API_KEY || '', - model = process.env.LLM_MODEL || DEFAULT_MODEL, - maxTokens = 1024, - controller, + question, + context, + baseUrl = process.env.LLM_BASE_URL || '', + apiKey = process.env.LLM_API_KEY || '', + model = process.env.LLM_MODEL || DEFAULT_MODEL, + maxTokens = 1024, + controller, }) { - const docsBlock = context - .map((c, i) => `[${i + 1}] ${c.title}\n${c.text}`) - .join('\n\n'); + const docsBlock = context + .map((c, i) => `[${i + 1}] ${c.title}\n${c.text}`) + .join('\n\n'); - const userContent = - 'ДОКУМЕНТАЦИЯ:\n' + - (docsBlock || '(нет релевантных фрагментов)') + - '\n\nВОПРОС ПОЛЬЗОВАТЕЛЯ:\n' + - String(question ?? ''); + const userContent = + 'ДОКУМЕНТАЦИЯ:\n' + + (docsBlock || '(нет релевантных фрагментов)') + + '\n\nВОПРОС ПОЛЬЗОВАТЕЛЯ:\n' + + String(question ?? ''); - const messages = [ - { role: 'system', content: STAGE2_SYSTEM_PROMPT }, - { role: 'user', content: userContent }, - ]; + const messages = [ + { role: 'system', content: STAGE2_SYSTEM_PROMPT }, + { role: 'user', content: userContent }, + ]; - let content; - try { - content = await llmChat({ - messages, - maxTokens, - temperature: 0.2, - controller, - baseUrl, - apiKey, - model, - }); - } catch (err) { - return { ok: false, error: err && err.code ? err.code : 'stage2_failed' }; - } + let content; + try { + content = await llmChat({ + messages, + maxTokens, + temperature: 0.2, + controller, + baseUrl, + apiKey, + model, + }); + } catch (err) { + return { ok: false, error: err && err.code ? err.code : 'stage2_failed' }; + } - // Проверяем наличие обязательных заголовков (П.12). - const missing = REQUIRED_HEADERS.filter((h) => !content.includes(h + ':')); - if (missing.length > 0) { - return { ok: false, error: 'bad_llm_output' }; - } + // Проверяем наличие обязательных заголовков (П.12). + const missing = REQUIRED_HEADERS.filter((h) => !content.includes(h + ':')); + if (missing.length > 0) { + return { ok: false, error: 'bad_llm_output' }; + } - return { ok: true, answer: content }; + return { ok: true, answer: content }; } module.exports = { - mergeSearchResults, - buildContext, - collectSources, - sanitizeSourceUrl, - sanitizeLocation, - isValidSourceUrl, - isResourcePage, - stage2Generate, - REQUIRED_HEADERS, + mergeSearchResults, + buildContext, + collectSources, + sanitizeSourceUrl, + sanitizeLocation, + isValidSourceUrl, + isResourcePage, + stage2Generate, + REQUIRED_HEADERS, };