diff --git a/orchestrator.js b/orchestrator.js index 1884af0..742b9af 100644 --- a/orchestrator.js +++ b/orchestrator.js @@ -27,22 +27,35 @@ const HTTPS = require('https'); const TOTAL_DEADLINE_MS = ENV.ASSIST_TOTAL_DEADLINE_MS || 40000; const RETRY_STAGE1_MIN_BUDGET_MS = 15000; const STAND_RE = /^[A-Za-z0-9._-]+$/; +const INDEX_TTL_MS = Number(process.env.ASSIST_INDEX_TTL_MS) || 600000; + +// Кэш индекса по stand (в памяти): экономит HTTP-запрос и парсинг на каждом вопросе. +const indexCache = new Map(); /** * Загружает search_index.json с VM_DOCS_BASE_URL//search/search_index.json. - * Кэширует результат в памяти (на стенд). + * Мемоизирует результат по stand с TTL. При сбое возвращает свежий кэш, если есть. * * @param {string} stand * @param {string} vmDocsBaseUrl * @returns {Promise} - index из loadIndex() или null. */ async function fetchIndex(stand, vmDocsBaseUrl) { + const now = Date.now(); + const cached = indexCache.get(stand); + if (cached && (now - cached.at) < INDEX_TTL_MS) { + return cached.index; + } try { const url = `${vmDocsBaseUrl.replace(/\/+$/, '')}/${stand}/search/search_index.json`; const mod = url.startsWith('https:') ? HTTPS : HTTP; const text = await httpGet(mod, url); - return loadIndex(text); + const index = loadIndex(text); + indexCache.set(stand, { index, at: now }); + return index; } catch (e) { + // Свежий кэш лучше ошибки: продолжаем работу на устаревшем индексе. + if (cached) return cached.index; return null; } } @@ -125,6 +138,9 @@ async function handleAssist(req, res, config) { const start = Date.now(); let holderActive = true; + // Общий бюджет времени: по истечении прерываем вызовы ЛЛМ. + const deadlineTimer = setTimeout(() => controller.abort(), TOTAL_DEADLINE_MS); + // Прерывание LLM при закрытии соединения клиентом. const onClose = () => { inflightEntry.holderCount -= 1; @@ -135,6 +151,7 @@ async function handleAssist(req, res, config) { req.on('close', onClose); const release = () => { + clearTimeout(deadlineTimer); try { sem.release(); } catch { } req.removeListener('close', onClose); config.shared.inflight.delete(normKey); @@ -149,17 +166,34 @@ async function handleAssist(req, res, config) { cache.set(normKey, body); sendJson(res, 200, body); for (const w of inflightEntry.waiters) w.succeed(body); + logAssist('ok', stand, question.length, result.meta, Date.now() - start); } catch (err) { holderActive = false; const code = err && err.code ? err.code : 'internal_error'; const status = mapPipelineError(code); for (const w of inflightEntry.waiters) w.fail(status, code); jsonError(res, status, code, err && err.message ? err.message : 'internal error'); + logAssist('error', stand, question.length, { error: code }, Date.now() - start); } finally { release(); } } +/** Однострочный JSON-лог без текста запроса (7.11 брифа). */ +function logAssist(status, stand, questionLen, meta, totalMs) { + try { + const line = JSON.stringify({ + t: new Date().toISOString(), + status, + stand, + question_len: questionLen, + total_ms: totalMs, + ...meta, + }); + process.stdout.write(line + '\n'); + } catch { /* лог не должен ронять */ } +} + /** Возвращает IP клиента (7.3: X-Forwarded-For только для доверенных proxy). */ function clientIp(req) { const trusted = (ENV.ASSIST_TRUSTED_PROXY_CIDR || '').trim(); @@ -202,6 +236,7 @@ async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) { throw { code: 'index_unavailable', message: 'search index unavailable' }; } const resourceTypes = index.listResourceTypes(); + const tIndex = Date.now(); // Этап 1 (resource_type). let resourceType = null; @@ -215,11 +250,13 @@ async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) { stage1Ok = r1.ok; if (r1.ok && r1.resourceType) resourceType = r1.resourceType; } + const tStage1 = Date.now(); // Поиск по токенам исходного запроса — всегда (параллельно уже можно, но для простоты // объединяем в mergeSearchResults, который сам делает поиск по query и по resource_type). const merged = mergeSearchResults(index, question, resourceType, { topK: 5 }); const context = buildContext(merged, 1500); + const tSearch = Date.now(); // Этап 2. const r2 = await stage2Generate({ @@ -231,6 +268,7 @@ async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) { maxTokens: 2048, controller, }); + const tStage2 = Date.now(); if (!r2.ok) { throw { code: r2.error, message: r2.error === 'bad_llm_output' ? 'bad llm output' : 'llm error' }; @@ -246,6 +284,10 @@ async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) { resource_type: resourceType, stage1_ok: stage1Ok, elapsed_ms: Date.now() - t1, + index_ms: tIndex - t1, + stage1_ms: tStage1 - tIndex, + search_ms: tSearch - tStage1, + stage2_ms: tStage2 - tSearch, }, }; } diff --git a/search.js b/search.js index 30215ac..11ea462 100644 --- a/search.js +++ b/search.js @@ -145,13 +145,19 @@ function loadIndex(rawJson) { index = { docs: Array.isArray(index) ? index : [] }; } + const docs = Array.isArray(index.docs) ? index.docs : []; + // Предрасчёт: списки ресурсов считаются ОДИН раз при загрузке индекса, + // а не на каждый запрос (экономия CPU на regex по всем докам). + const resourceTypes = extractResourceTypes({ docs }); + const pages = resourcePages({ docs }); + return { - docs: Array.isArray(index.docs) ? index.docs : [], + docs, listResourceTypes() { - return extractResourceTypes({ docs: this.docs }); + return resourceTypes; }, resourcePages() { - return resourcePages({ docs: this.docs }); + return pages; }, }; }