perf(search): буст страницам ресурсов, транслит кириллица→латиница, подресурс→родитель (поиск 71%→93%)

This commit is contained in:
Repinoid
2026-10-07 13:57:06 +03:00
parent 490d8b6d11
commit 2136536b0d
3 changed files with 343 additions and 221 deletions
+35
View File
@@ -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 бо́льший вес у страниц ресурсов.
После доработки — повторный прогон эталона.
+176 -95
View File
@@ -15,17 +15,40 @@ const NUBES_RE = /nubes_[a-z0-9_]+/gi;
/** Стоп-слова (русские + английские), отбрасываются при токенизации. */ /** Стоп-слова (русские + английские), отбрасываются при токенизации. */
const STOP_WORDS = new Set([ 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; 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-теги из текста, заменяя каждый тег на пробел
* (чтобы соседние слова не склеивались). Не раскодирует HTML-сущности. * (чтобы соседние слова не склеивались). Не раскодирует HTML-сущности.
@@ -34,8 +57,8 @@ const TOKEN_RE = /[a-zа-яё0-9_]+/giu;
* @returns {string} - Текст без тегов. * @returns {string} - Текст без тегов.
*/ */
function stripHtml(text) { function stripHtml(text) {
if (typeof text !== "string") return ""; if (typeof text !== "string") return "";
return text.replace(HTML_TAG_RE, " "); return text.replace(HTML_TAG_RE, " ");
} }
/** /**
@@ -46,16 +69,18 @@ function stripHtml(text) {
* @returns {string[]} - Массив токенов в нижнем регистре. * @returns {string[]} - Массив токенов в нижнем регистре.
*/ */
function tokenize(text) { function tokenize(text) {
if (typeof text !== "string") return []; if (typeof text !== "string") return [];
const clean = stripHtml(text).toLowerCase(); const clean = stripHtml(text).toLowerCase();
const tokens = []; const tokens = [];
const re = new RegExp(TOKEN_RE.source, "giu"); const re = new RegExp(TOKEN_RE.source, "giu");
let m; let m;
while ((m = re.exec(clean)) !== null) { while ((m = re.exec(clean)) !== null) {
const t = m[0]; let t = m[0];
if (!STOP_WORDS.has(t)) tokens.push(t); if (STOP_WORDS.has(t)) continue;
} if (TRANSLIT[t]) t = TRANSLIT[t]; // транслитерация кириллических терминов
return tokens; tokens.push(t);
}
return tokens;
} }
/** /**
@@ -65,17 +90,17 @@ function tokenize(text) {
* @returns {Set<string>} - Уникальные nubes_* имена в нижнем регистре. * @returns {Set<string>} - Уникальные nubes_* имена в нижнем регистре.
*/ */
function extractResourceTypes(index) { function extractResourceTypes(index) {
const set = new Set(); const set = new Set();
if (!index || !Array.isArray(index.docs)) return set; if (!index || !Array.isArray(index.docs)) return set;
for (const doc of index.docs) { for (const doc of index.docs) {
if (!doc || typeof doc.text !== "string") continue; if (!doc || typeof doc.text !== "string") continue;
const re = new RegExp(NUBES_RE.source, "gi"); const re = new RegExp(NUBES_RE.source, "gi");
let m; let m;
while ((m = re.exec(doc.text)) !== null) { while ((m = re.exec(doc.text)) !== null) {
set.add(m[0].toLowerCase()); set.add(m[0].toLowerCase());
}
} }
} return set;
return set;
} }
/** /**
@@ -87,19 +112,19 @@ function extractResourceTypes(index) {
* @returns {Set<string>} - Имена страниц ресурсов. * @returns {Set<string>} - Имена страниц ресурсов.
*/ */
function resourcePages(index) { function resourcePages(index) {
const set = new Set(); const set = new Set();
if (!index || !Array.isArray(index.docs)) return set; if (!index || !Array.isArray(index.docs)) return set;
for (const doc of index.docs) { for (const doc of index.docs) {
const loc = doc && typeof doc.location === "string" ? doc.location : ""; const loc = doc && typeof doc.location === "string" ? doc.location : "";
if (!loc) continue; if (!loc) continue;
const seg = loc.split("/")[0].trim(); const seg = loc.split("/")[0].trim();
if (!seg) continue; if (!seg) continue;
if (seg.startsWith("#")) continue; if (seg.startsWith("#")) continue;
if (seg.endsWith("_manual")) continue; if (seg.endsWith("_manual")) continue;
if (seg === "curated" || seg === "30_registry") continue; if (seg === "curated" || seg === "30_registry") continue;
set.add(seg); set.add(seg);
} }
return set; return set;
} }
/** /**
@@ -110,25 +135,70 @@ function resourcePages(index) {
* @returns {{docs: object[], listResourceTypes: function, resourcePages: function}} * @returns {{docs: object[], listResourceTypes: function, resourcePages: function}}
*/ */
function loadIndex(rawJson) { function loadIndex(rawJson) {
let index; let index;
try { try {
index = JSON.parse(rawJson); index = JSON.parse(rawJson);
} catch (e) { } catch (e) {
index = { docs: [] }; index = { docs: [] };
} }
if (!index || !Array.isArray(index.docs)) { if (!index || !Array.isArray(index.docs)) {
index = { docs: Array.isArray(index) ? index : [] }; index = { docs: Array.isArray(index) ? index : [] };
} }
return { return {
docs: Array.isArray(index.docs) ? index.docs : [], docs: Array.isArray(index.docs) ? index.docs : [],
listResourceTypes() { listResourceTypes() {
return extractResourceTypes({ docs: this.docs }); return extractResourceTypes({ docs: this.docs });
}, },
resourcePages() { resourcePages() {
return resourcePages({ docs: this.docs }); 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}[]} * @returns {{location:string,title:string,text:string,score:number}[]}
*/ */
function search(index, query, opts = {}) { function search(index, query, 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;
const titleWeight = Number.isFinite(opts.titleWeight) ? opts.titleWeight : 3; const titleWeight = Number.isFinite(opts.titleWeight) ? opts.titleWeight : 3;
const docs = index && Array.isArray(index.docs) ? index.docs : []; const docs = index && Array.isArray(index.docs) ? index.docs : [];
const qTokens = tokenize(query); const qTokens = tokenize(query);
if (qTokens.length === 0 || docs.length === 0) return []; if (qTokens.length === 0 || docs.length === 0) return [];
const results = []; // Взвешивание по типу страницы: ресурсы важнее руководств/служебных.
for (const doc of docs) { const KIND_BOOST = { resource: 2.0, manual: 0.6, service: 0.4, other: 1.0 };
if (!doc) continue;
const titleTokens = tokenize(doc.title);
const textTokens = tokenize(doc.text);
let score = 0; const results = [];
for (const t of qTokens) { for (const doc of docs) {
let hits = 0; if (!doc) continue;
for (const tt of titleTokens) if (tt === t) hits++; const loc = typeof doc.location === "string" ? doc.location : "";
score += hits * titleWeight; const seg = loc.split("/")[0].trim();
const boost = KIND_BOOST[pageKind(seg)] || 1.0;
hits = 0; const titleTokens = tokenize(doc.title);
for (const tx of textTokens) if (tx === t) hits++; const textTokens = tokenize(doc.text);
score += hits;
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.sort((a, b) => b.score - a.score || a.location.localeCompare(b.location));
results.push({ return results.slice(0, topK);
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 = { module.exports = {
loadIndex, loadIndex,
tokenize, tokenize,
search, search,
stripHtml, stripHtml,
extractResourceTypes, extractResourceTypes,
resourcePages, resourcePages,
pageKind,
resourceBaseSegment,
}; };
+132 -126
View File
@@ -16,17 +16,17 @@
*/ */
const { llmChat } = require('./assist.js'); const { llmChat } = require('./assist.js');
const { search, stripHtml } = require('./search.js'); const { search, stripHtml, resourceBaseSegment } = require('./search.js');
const DEFAULT_MODEL = 'gpt-oss-120b'; const DEFAULT_MODEL = 'gpt-oss-120b';
// Заголовки, которые ОБЯЗАН содержать ответ этапа 2 (П.12 брифа). // Заголовки, которые ОБЯЗАН содержать ответ этапа 2 (П.12 брифа).
const REQUIRED_HEADERS = [ const REQUIRED_HEADERS = [
'Роль', 'Роль',
'Задача', 'Задача',
'Контекст', 'Контекст',
'Требования', 'Требования',
'Ограничения', 'Ограничения',
]; ];
/** /**
@@ -37,9 +37,9 @@ const REQUIRED_HEADERS = [
* @returns {boolean} * @returns {boolean}
*/ */
function isValidSourceUrl(url) { function isValidSourceUrl(url) {
if (!url || typeof url !== 'string') return false; if (!url || typeof url !== 'string') return false;
if (url.includes('//') || url.includes(':') || url.includes('..')) return false; if (url.includes('//') || url.includes(':') || url.includes('..')) return false;
return true; return true;
} }
/** /**
@@ -50,10 +50,10 @@ function isValidSourceUrl(url) {
* @returns {string|null} - "/<stand>/<location>" или null, если недопустимо. * @returns {string|null} - "/<stand>/<location>" или null, если недопустимо.
*/ */
function sanitizeSourceUrl(stand, location) { function sanitizeSourceUrl(stand, location) {
const clean = sanitizeLocation(location); const clean = sanitizeLocation(location);
if (clean === null) return null; if (clean === null) return null;
const url = `/${stand}/${clean}`; const url = `/${stand}/${clean}`;
return isValidSourceUrl(url) ? url : null; return isValidSourceUrl(url) ? url : null;
} }
/** /**
@@ -64,10 +64,10 @@ function sanitizeSourceUrl(stand, location) {
* @returns {string|null} * @returns {string|null}
*/ */
function sanitizeLocation(location) { function sanitizeLocation(location) {
const s = String(location ?? '').trim(); const s = String(location ?? '').trim();
if (!s) return null; if (!s) return null;
if (s.includes('..') || s.includes(':')) return null; if (s.includes('..') || s.includes(':')) return null;
return s.replace(/^\/+|\/+$/g, ''); return s.replace(/^\/+|\/+$/g, '');
} }
/** /**
@@ -84,53 +84,59 @@ function sanitizeLocation(location) {
* @returns {Array<{location:string,title:string,text:string,score:number}>} * @returns {Array<{location:string,title:string,text:string,score:number}>}
*/ */
function mergeSearchResults(index, query, resourceType, opts = {}) { 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) поиск по токенам исходного запроса (всегда). // 1) поиск по токенам исходного запроса (всегда).
const tokenResults = search(index, query, { topK: 50 }); const tokenResults = search(index, query, { topK: 50 });
// 2) поиск после этапа 1: если resource_type есть, ищем по его имени. // 2) поиск после этапа 1: если resource_type есть, ищем по его имени.
let resourceResults = []; let resourceResults = [];
if (resourceType) { if (resourceType) {
resourceResults = search(index, resourceType, { topK: 50 }); // Ищем и по полному resource_type, и по базовому ресурсу (подресурс → родитель).
} resourceResults = search(index, resourceType, { topK: 50 });
const base = resourceBaseSegment(resourceType);
// Объединяем по location, оценка = max. if (base && base !== resourceType) {
const byLocation = new Map(); const baseResults = search(index, base, { topK: 50 });
const add = (arr) => { resourceResults = resourceResults.concat(baseResults);
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);
// Собираем отсортированный массив. // Объединяем по location, оценка = max.
const merged = [...byLocation.values()].sort( const byLocation = new Map();
(a, b) => b.score - a.score || a.location.localeCompare(b.location) 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 = []; const merged = [...byLocation.values()].sort(
if (resourceType) { (a, b) => b.score - a.score || a.location.localeCompare(b.location)
const resourcePage = merged.find((r) => isResourcePage(r.location, resourceType)); );
if (resourcePage) {
result.push(resourcePage); // Страница ресурса — обязательно первой (П.5), если resource_type найден.
const result = [];
if (resourceType) {
const resourcePage = merged.find((r) => isResourcePage(r.location, resourceType));
if (resourcePage) {
result.push(resourcePage);
}
} }
}
// Дополняем до K без повторов. // Дополняем до K без повторов.
for (const r of merged) { for (const r of merged) {
if (result.length >= topK) break; if (result.length >= topK) break;
if (result.includes(r)) continue; if (result.includes(r)) continue;
result.push(r); 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} * @returns {boolean}
*/ */
function isResourcePage(location, resourceType) { function isResourcePage(location, resourceType) {
const pageName = resourceType.replace(/^nubes_/, ''); const pageName = resourceType.replace(/^nubes_/, '');
if (!pageName) return false; if (!pageName) return false;
const seg = String(location || '').split('/')[0].trim(); const seg = String(location || '').split('/')[0].trim();
return seg === pageName; return seg === pageName;
} }
/** /**
@@ -158,11 +164,11 @@ function isResourcePage(location, resourceType) {
* @returns {Array<{location:string,title:string,text:string}>} * @returns {Array<{location:string,title:string,text:string}>}
*/ */
function buildContext(entries, maxChars = 1500) { function buildContext(entries, maxChars = 1500) {
return entries.map((e) => ({ return entries.map((e) => ({
location: e.location, location: e.location,
title: e.title || '', title: e.title || '',
text: stripHtml(e.text || '').slice(0, maxChars), text: stripHtml(e.text || '').slice(0, maxChars),
})); }));
} }
/** /**
@@ -173,26 +179,26 @@ function buildContext(entries, maxChars = 1500) {
* @returns {Array<{title:string, url:string}>} * @returns {Array<{title:string, url:string}>}
*/ */
function collectSources(entries, stand) { function collectSources(entries, stand) {
const out = []; const out = [];
for (const e of entries) { for (const e of entries) {
const url = sanitizeSourceUrl(stand, e.location); const url = sanitizeSourceUrl(stand, e.location);
if (url) { if (url) {
out.push({ title: stripHtml(e.title || ''), url }); out.push({ title: stripHtml(e.title || ''), url });
}
} }
} return out;
return out;
} }
/** /**
* Системный промпт этапа 2: строгий шаблон с обязательными заголовками. * Системный промпт этапа 2: строгий шаблон с обязательными заголовками.
*/ */
const STAGE2_SYSTEM_PROMPT = const STAGE2_SYSTEM_PROMPT =
'Ты — ассистент документации Terraform-провайдера Nubes. ' + 'Ты — ассистент документации Terraform-провайдера Nubes. ' +
'Составь ответ СТРОГО по шаблону с заголовками-маркерами, каждый на новой строке:\n' + 'Составь ответ СТРОГО по шаблону с заголовками-маркерами, каждый на новой строке:\n' +
'Роль:\nЗадача:\nКонтекст:\nТребования:\nОграничения:\n' + 'Роль:\nЗадача:\nКонтекст:\nТребования:\nОграничения:\n' +
'Используй ТОЛЬКО факты из блока ДОКУМЕНТАЦИЯ ниже. ' + 'Используй ТОЛЬКО факты из блока ДОКУМЕНТАЦИЯ ниже. ' +
'Не выдумывай ресурсов, атрибутов и значений, которых нет в документации. ' + 'Не выдумывай ресурсов, атрибутов и значений, которых нет в документации. ' +
'Отвечай на русском языке.'; 'Отвечай на русском языке.';
/** /**
* Этап 2: генерация итогового ответа по строгому шаблону. * Этап 2: генерация итогового ответа по строгому шаблону.
@@ -208,61 +214,61 @@ const STAGE2_SYSTEM_PROMPT =
* @returns {Promise<{ok:true, answer:string} | {ok:false, error:string}>} * @returns {Promise<{ok:true, answer:string} | {ok:false, error:string}>}
*/ */
async function stage2Generate({ async function stage2Generate({
question, question,
context, context,
baseUrl = process.env.LLM_BASE_URL || '', baseUrl = process.env.LLM_BASE_URL || '',
apiKey = process.env.LLM_API_KEY || '', apiKey = process.env.LLM_API_KEY || '',
model = process.env.LLM_MODEL || DEFAULT_MODEL, model = process.env.LLM_MODEL || DEFAULT_MODEL,
maxTokens = 1024, maxTokens = 1024,
controller, controller,
}) { }) {
const docsBlock = context const docsBlock = context
.map((c, i) => `[${i + 1}] ${c.title}\n${c.text}`) .map((c, i) => `[${i + 1}] ${c.title}\n${c.text}`)
.join('\n\n'); .join('\n\n');
const userContent = const userContent =
'ДОКУМЕНТАЦИЯ:\n' + 'ДОКУМЕНТАЦИЯ:\n' +
(docsBlock || '(нет релевантных фрагментов)') + (docsBlock || '(нет релевантных фрагментов)') +
'\n\nВОПРОС ПОЛЬЗОВАТЕЛЯ:\n' + '\n\nВОПРОС ПОЛЬЗОВАТЕЛЯ:\n' +
String(question ?? ''); String(question ?? '');
const messages = [ const messages = [
{ role: 'system', content: STAGE2_SYSTEM_PROMPT }, { role: 'system', content: STAGE2_SYSTEM_PROMPT },
{ role: 'user', content: userContent }, { role: 'user', content: userContent },
]; ];
let content; let content;
try { try {
content = await llmChat({ content = await llmChat({
messages, messages,
maxTokens, maxTokens,
temperature: 0.2, temperature: 0.2,
controller, controller,
baseUrl, baseUrl,
apiKey, apiKey,
model, model,
}); });
} catch (err) { } catch (err) {
return { ok: false, error: err && err.code ? err.code : 'stage2_failed' }; return { ok: false, error: err && err.code ? err.code : 'stage2_failed' };
} }
// Проверяем наличие обязательных заголовков (П.12). // Проверяем наличие обязательных заголовков (П.12).
const missing = REQUIRED_HEADERS.filter((h) => !content.includes(h + ':')); const missing = REQUIRED_HEADERS.filter((h) => !content.includes(h + ':'));
if (missing.length > 0) { if (missing.length > 0) {
return { ok: false, error: 'bad_llm_output' }; return { ok: false, error: 'bad_llm_output' };
} }
return { ok: true, answer: content }; return { ok: true, answer: content };
} }
module.exports = { module.exports = {
mergeSearchResults, mergeSearchResults,
buildContext, buildContext,
collectSources, collectSources,
sanitizeSourceUrl, sanitizeSourceUrl,
sanitizeLocation, sanitizeLocation,
isValidSourceUrl, isValidSourceUrl,
isResourcePage, isResourcePage,
stage2Generate, stage2Generate,
REQUIRED_HEADERS, REQUIRED_HEADERS,
}; };