From 12d29ee98d7633bf9accc856c4857fa0259db567 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Mon, 12 Oct 2026 07:51:48 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BF=D0=BE=D0=B4=D1=80=D0=B5=D1=81=D1=83=D1=80=D1=81?= =?UTF-8?q?=D0=B0=D0=BC=20+=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20per-call=20=D1=82=D0=B0=D0=B9=D0=BC=D0=B0=D1=83=D1=82?= =?UTF-8?q?=20=D0=9B=D0=9B=D0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search(resourceBaseSegment): суффиксы-подресурсы перебираются по убыванию длины (KNOWN_RESOURCE_SUBS). Иначе "_user" матчился раньше "_sub_user" и nubes_s3_sub_user давал "s3_sub" вместо "s3" — страница ресурса не находилась. assist(llmChat): реально применяет ASSIST_LLM_TIMEOUT_MS — таймаут ОДНОГО вызова (покрывает и чтение тела ответа). Таймаут возвращает отдельный код llm_timeout, отмена вызывающим (дедлайн/закрытие соединения) — по-прежнему aborted, чтобы stage2 различал причины. stage1: убран дублирующий AbortController (таймаут теперь только в llmChat). orchestrator: llm_timeout -> HTTP 504. --- assist.js | 163 ++++++++++++++++++++++++++++++------------------ orchestrator.js | 1 + search.js | 27 +++++--- stage1.js | 48 +++++--------- 4 files changed, 139 insertions(+), 100 deletions(-) diff --git a/assist.js b/assist.js index ee21ac7..798e500 100644 --- a/assist.js +++ b/assist.js @@ -266,7 +266,8 @@ function createSemaphore(max) { * @param {{ * messages:Array, // массив сообщений {role, content} * maxTokens?:number, - * controller?:AbortController, // сигнал отмены + * controller?:AbortController, // сигнал отмены вызывающего (дедлайн/закрытие соединения) + * timeoutMs?:number, // таймаут ОДНОГО вызова ЛЛМ (по умолч. ASSIST_LLM_TIMEOUT_MS) * baseUrl?:string, * apiKey?:string, * model?:string, @@ -280,6 +281,7 @@ async function llmChat({ messages, maxTokens, controller, + timeoutMs = ENV.ASSIST_LLM_TIMEOUT_MS, baseUrl = ENV.LLM_BASE_URL, apiKey = ENV.LLM_API_KEY, model = ENV.LLM_MODEL, @@ -301,72 +303,113 @@ async function llmChat({ payload.response_format = responseFormat; } - const signal = controller ? controller.signal : undefined; + // Составной сигнал отмены. fetch принимает РОВНО ОДИН signal, поэтому, когда + // задан индивидуальный таймаут вызова (ASSIST_LLM_TIMEOUT_MS), создаём + // дочерний AbortController, который абортится ЛИБО по таймауту, ЛИБО при + // аборте вызывающего (дедлайн всего запроса / закрытие соединения клиентом). + // Без таймаута используем сигнал вызывающего напрямую. Дочерний контроллер + // покрывает ВЕСЬ вызов, включая чтение тела ответа (response.json()). + let signal = controller ? controller.signal : undefined; + let timeoutTimer = null; + let onParentAbort = null; + if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) { + const ownController = new AbortController(); + signal = ownController.signal; + if (controller) { + onParentAbort = () => ownController.abort(); + if (controller.signal.aborted) ownController.abort(); + else controller.signal.addEventListener('abort', onParentAbort, { once: true }); + } + timeoutTimer = setTimeout(() => ownController.abort(), timeoutMs); + } - let response; try { - response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - signal, - }); - } catch (err) { - if (err && err.name === 'AbortError') { - throw { code: 'aborted', message: 'llm request aborted (AbortError)' }; - } - throw new Error('llm request failed (network)'); - } - - // Не-200 → ошибка со статусом (без apiKey в тексте!). - if (!response.ok) { - const status = response.status; - const retryAfter = response.headers.get('retry-after'); - const err = { code: 'llm_http_error', status, retryAfter: undefined }; - - if (status === 429) { - err.code = 'rate_limited'; - // retry-after может быть секундами или HTTP-датой. - if (retryAfter) { - const secs = parseRetryAfter(retryAfter); - if (secs !== null) err.retryAfter = secs; - } - } - - // Читаем тело ошибки, но никогда не упоминаем apiKey. - let detail = ''; + let response; try { - detail = await response.text(); - if (detail && detail.length > 500) detail = detail.slice(0, 500); - } catch { - detail = ''; + response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + signal, + }); + } catch (err) { + if (err && err.name === 'AbortError') { + // Различаем причину: отмена вызывающим vs. собственный таймаут + // вызова (иначе stage2 не смог бы отличить таймаут от ухода + // клиента и корректно решить, повторять ли вызов). + if (controller && controller.signal.aborted) { + throw { code: 'aborted', message: 'llm request aborted (AbortError)' }; + } + throw { code: 'llm_timeout', message: `llm call exceeded timeout ${timeoutMs} ms` }; + } + throw new Error('llm request failed (network)'); } - if (detail) err.message = `llm error ${status}: ${detail}`; - else err.message = `llm error ${status}`; + // Не-200 → ошибка со статусом (без apiKey в тексте!). + if (!response.ok) { + const status = response.status; + const retryAfter = response.headers.get('retry-after'); + const err = { code: 'llm_http_error', status, retryAfter: undefined }; + + if (status === 429) { + err.code = 'rate_limited'; + // retry-after может быть секундами или HTTP-датой. + if (retryAfter) { + const secs = parseRetryAfter(retryAfter); + if (secs !== null) err.retryAfter = secs; + } + } + + // Читаем тело ошибки, но никогда не упоминаем apiKey. + let detail = ''; + try { + detail = await response.text(); + if (detail && detail.length > 500) detail = detail.slice(0, 500); + } catch { + detail = ''; + } + if (detail) err.message = `llm error ${status}: ${detail}`; + else err.message = `llm error ${status}`; + + throw err; + } + + const data = await response.json(); + + // Собираем токены (usage) для отображения пользователю. + if (usageRef && data && data.usage) { + usageRef.prompt = (usageRef.prompt || 0) + (data.usage.prompt_tokens || 0); + usageRef.completion = (usageRef.completion || 0) + (data.usage.completion_tokens || 0); + usageRef.total = (usageRef.total || 0) + (data.usage.total_tokens || 0); + } + + const content = data && data.choices && data.choices[0] && + data.choices[0].message && data.choices[0].message.content; + + if (typeof content !== 'string' || content.trim() === '') { + throw { code: 'empty_content', message: 'llm returned empty content' }; + } + + return content; + } catch (err) { + // Аборт мог прилететь не только из fetch, но и при чтении тела ответа + // (response.json() выше) — нормализуем и этот случай тем же образом. + if (err && err.name === 'AbortError') { + if (controller && controller.signal.aborted) { + throw { code: 'aborted', message: 'llm request aborted (AbortError)' }; + } + throw { code: 'llm_timeout', message: `llm call exceeded timeout ${timeoutMs} ms` }; + } throw err; + } finally { + // Снимаем таймер и слушателя родителя на всех путях (успех/ошибка), + // чтобы не оставлять висящих таймеров и подписок на сигнале. + if (timeoutTimer) clearTimeout(timeoutTimer); + if (controller && onParentAbort) controller.signal.removeEventListener('abort', onParentAbort); } - - const data = await response.json(); - - // Собираем токены (usage) для отображения пользователю. - if (usageRef && data && data.usage) { - usageRef.prompt = (usageRef.prompt || 0) + (data.usage.prompt_tokens || 0); - usageRef.completion = (usageRef.completion || 0) + (data.usage.completion_tokens || 0); - usageRef.total = (usageRef.total || 0) + (data.usage.total_tokens || 0); - } - - const content = data && data.choices && data.choices[0] && - data.choices[0].message && data.choices[0].message.content; - - if (typeof content !== 'string' || content.trim() === '') { - throw { code: 'empty_content', message: 'llm returned empty content' }; - } - - return content; } /** diff --git a/orchestrator.js b/orchestrator.js index 507d069..0e46c97 100644 --- a/orchestrator.js +++ b/orchestrator.js @@ -342,6 +342,7 @@ function mapPipelineError(code) { case 'index_unavailable': return 503; case 'empty_content': return 502; case 'aborted': return 499; + case 'llm_timeout': return 504; case 'rate_limited': return 503; default: return 502; } diff --git a/search.js b/search.js index d61c0e6..ab2a2b9 100644 --- a/search.js +++ b/search.js @@ -190,6 +190,22 @@ function pageKind(seg) { return "resource"; } +/** + * Суффиксы-подресурсы в именах `nubes_*`, по которым отрезается хвост до + * базового ресурса (например, `nubes_postgres_database` → `postgres`). + * + * ⚠️ Порядок перебора критичен: суффиксы идут по УБЫВАНИЮ длины, поэтому + * сортируем массив один раз при загрузке модуля. Иначе короткий `_user` + * затеняет более длинный `_sub_user`: для `nubes_s3_sub_user` (rest = + * "s3_sub_user") отрезался бы `_user`, и получалось "s3_sub" вместо "s3", + * из-за чего страница ресурса не находилась (`isResourcePage`). + */ +const KNOWN_RESOURCE_SUBS = [ + "_database", "_user", "_topic", "_backup", "_snat", "_record", + "_sub_user", "_ip_allocation", "_vdc", "_kv", "_policy", "_secrets", + "_team", "_redeploy", "_group", +].sort((a, b) => b.length - a.length); + /** * Преобразует имя ресурса nubes_<база>_<подресурс> в базовое имя страницы * (<база>). Для `nubes_postgres_database` → `postgres`; `nubes_postgres` → `postgres`. @@ -201,15 +217,10 @@ 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", - ]; + // Известные суффиксы-подресурсы; перебор идёт от самого длинного к самому + // короткому (порядок задан в KNOWN_RESOURCE_SUBS, см. определение выше). let base = rest; - for (const sub of knownSubs) { + for (const sub of KNOWN_RESOURCE_SUBS) { if (rest.endsWith(sub)) { base = rest.slice(0, rest.length - sub.length); break; diff --git a/stage1.js b/stage1.js index a203b45..81e2975 100644 --- a/stage1.js +++ b/stage1.js @@ -131,38 +131,22 @@ async function stage1ExtractResourceType(question, { const messages = buildStage1Messages(question, types); - const attempt = async (maxTokens) => { - const params = { - messages, - maxTokens, - temperature: 0, - responseFormat: { type: 'json_object' }, - controller, - baseUrl, - apiKey, - model, - usageRef, - }; - let timer = null; - let ctrl = controller; - if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) { - ctrl = new AbortController(); - timer = setTimeout(() => ctrl.abort(), timeoutMs); - const forward = () => ctrl.abort(); - if (controller) { - if (controller.signal.aborted) ctrl.abort(); - else controller.signal.addEventListener('abort', forward, { once: true }); - } - params.controller = ctrl; - try { - return await llmChat(params); - } finally { - clearTimeout(timer); - if (controller) controller.signal.removeEventListener('abort', forward); - } - } - return llmChat(params); - }; + // Индивидуальный таймаут одного вызова ЛЛМ и составной сигнал отмены + // реализованы ВНУТРИ llmChat (единая точка). Здесь только прокидываем опции, + // чтобы не создавать ДВОЙНОЙ AbortController. timeoutMs === undefined → + // llmChat применит дефолт ASSIST_LLM_TIMEOUT_MS. + const attempt = async (maxTokens) => llmChat({ + messages, + maxTokens, + temperature: 0, + responseFormat: { type: 'json_object' }, + controller, + timeoutMs, + baseUrl, + apiKey, + model, + usageRef, + }); let content; try {