diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index a07b4ad..d57f8f4 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -16,15 +16,20 @@ const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200; const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000; const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000; const VAT_METADATA_PROBE_LIMIT = 100; -const VAT_SOURCE_PROBE_MAX_OBJECTS = 6; -const VAT_METADATA_PROBE_TYPES = ["РегистрНакопления", "Документ"]; +const VAT_SOURCE_PROBE_MAX_OBJECTS = 4; +const VAT_METADATA_PROBE_TYPES = ["РегистрНакопления"]; const VAT_METADATA_PROBE_MASKS = ["ндс", "книгапродаж", "книгапокупок", "счетфактур"]; -const VAT_METADATA_PROBE_CONCURRENCY = 4; -const VAT_METADATA_PROBE_TIMEOUT_MS = 800; -const VAT_METADATA_PROBE_RETRY_TIMEOUT_MS = 1_200; -const VAT_OBJECT_PROBE_CONCURRENCY = 4; -const VAT_OBJECT_PROBE_TIMEOUT_MS = 800; -const VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS = 800; +const VAT_METADATA_PROBE_CONCURRENCY = 2; +const VAT_METADATA_PROBE_STAGGER_MS = 90; +const VAT_METADATA_PROBE_TIMEOUT_MS = 1_200; +const VAT_METADATA_PROBE_RETRY_TIMEOUT_MS = 1_800; +const VAT_METADATA_PROBE_RETRY_DELAY_MS = 140; +const VAT_OBJECT_PROBE_CONCURRENCY = 1; +const VAT_OBJECT_PROBE_STAGGER_MS = 120; +const VAT_OBJECT_PROBE_TIMEOUT_MS = 1_500; +const VAT_OBJECT_PROBE_ABORT_RETRY_TIMEOUT_MS = 2_200; +const VAT_OBJECT_PROBE_ABORT_RETRY_DELAY_MS = 180; +const VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS = 1_500; const PARTY_ANCHOR_STOPWORDS = new Set([ "ооо", "ао", @@ -75,6 +80,28 @@ const ACCOUNT_ALIAS_MAP = { "62": ["покупатель", "покупателями", "расчеты с покупателями"], "76": ["прочие расчеты", "прочими дебиторами и кредиторами"] }; +const VAT_FALLBACK_METADATA_OBJECTS = [ + { + fullName: "РегистрНакопления.НДСЗаписиКнигиПродаж", + synonym: "НДС Продажи", + objectType: "register" + }, + { + fullName: "РегистрНакопления.НДСЗаписиКнигиПокупок", + synonym: "НДС Покупки", + objectType: "register" + }, + { + fullName: "РегистрНакопления.НДСПредъявленный", + synonym: "НДС предъявленный", + objectType: "register" + }, + { + fullName: "РегистрНакопления.НДСВключенныйВСтоимость", + synonym: "НДС, включенный в стоимость", + objectType: "register" + } +]; const COUNTERPARTY_CATALOG_LOOKUP_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период, @@ -252,6 +279,13 @@ async function mapWithConcurrency(items, concurrency, worker) { await Promise.all(runners); return results; } +function sleepMs(ms) { + const delayMs = Number.isFinite(ms) ? Math.max(0, Math.trunc(ms)) : 0; + if (delayMs <= 0) { + return Promise.resolve(); + } + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} async function executeVatMetadataProbeRequest(request) { const firstAttempt = await (0, addressMcpClient_1.executeAddressMcpMetadata)({ ...request, @@ -260,6 +294,7 @@ async function executeVatMetadataProbeRequest(request) { if (!firstAttempt.error || !isAbortErrorMessage(firstAttempt.error)) { return firstAttempt; } + await sleepMs(VAT_METADATA_PROBE_RETRY_DELAY_MS); const retryLimit = Math.max(20, Math.min(request.limit, Math.trunc(request.limit / 2))); const retryAttempt = await (0, addressMcpClient_1.executeAddressMcpMetadata)({ ...request, @@ -301,6 +336,10 @@ function scoreVatMetadataObject(item) { } return score; } +function vatMetadataObjectPriority(item) { + const idx = VAT_FALLBACK_METADATA_OBJECTS.findIndex((fallback) => fallback.fullName === item.fullName); + return idx >= 0 ? idx : VAT_FALLBACK_METADATA_OBJECTS.length + 1; +} function buildVatObjectProbeQuery(object, asOfExpr, mode = "latest") { const orderClause = mode === "latest" ? "\nУПОРЯДОЧИТЬ ПО\n Движения.Период УБЫВ" : ""; if (object.objectType === "document") { @@ -363,7 +402,12 @@ async function probeVatDirectSources(filters) { name_mask: nameMask, limit: VAT_METADATA_PROBE_LIMIT }))); - const metadataResponses = await mapWithConcurrency(metadataRequests, VAT_METADATA_PROBE_CONCURRENCY, (request) => executeVatMetadataProbeRequest(request)); + const metadataResponses = await mapWithConcurrency(metadataRequests, VAT_METADATA_PROBE_CONCURRENCY, async (request, index) => { + if (index > 0 && VAT_METADATA_PROBE_STAGGER_MS > 0) { + await sleepMs(index * VAT_METADATA_PROBE_STAGGER_MS); + } + return executeVatMetadataProbeRequest(request); + }); const metadataOutcomes = metadataResponses.map((response, index) => ({ request: metadataRequests[index], response @@ -403,9 +447,27 @@ async function probeVatDirectSources(filters) { } const discoveredMetadataObjects = Array.from(deduplicatedObjects.values()) .filter((item) => isVatMetadataObject(item)) - .sort((a, b) => scoreVatMetadataObject(b) - scoreVatMetadataObject(a) || a.fullName.localeCompare(b.fullName, "ru")); - const metadataObjects = discoveredMetadataObjects.slice(0, VAT_SOURCE_PROBE_MAX_OBJECTS); - const probeRows = await mapWithConcurrency(metadataObjects, VAT_OBJECT_PROBE_CONCURRENCY, async (object) => { + .sort((a, b) => vatMetadataObjectPriority(a) - vatMetadataObjectPriority(b) || + scoreVatMetadataObject(b) - scoreVatMetadataObject(a) || + a.fullName.localeCompare(b.fullName, "ru")); + const mergedMetadataObjectsMap = new Map(); + for (const item of discoveredMetadataObjects) { + mergedMetadataObjectsMap.set(item.fullName, item); + } + for (const fallbackObject of VAT_FALLBACK_METADATA_OBJECTS) { + if (!mergedMetadataObjectsMap.has(fallbackObject.fullName)) { + mergedMetadataObjectsMap.set(fallbackObject.fullName, fallbackObject); + } + } + const metadataObjects = Array.from(mergedMetadataObjectsMap.values()) + .sort((a, b) => vatMetadataObjectPriority(a) - vatMetadataObjectPriority(b) || + scoreVatMetadataObject(b) - scoreVatMetadataObject(a) || + a.fullName.localeCompare(b.fullName, "ru")) + .slice(0, VAT_SOURCE_PROBE_MAX_OBJECTS); + const probeRows = await mapWithConcurrency(metadataObjects, VAT_OBJECT_PROBE_CONCURRENCY, async (object, index) => { + if (index > 0 && VAT_OBJECT_PROBE_STAGGER_MS > 0) { + await sleepMs(index * VAT_OBJECT_PROBE_STAGGER_MS); + } let probeResult = await (0, addressMcpClient_1.executeAddressMcpQuery)({ query: buildVatObjectProbeQuery(object, asOfExpr, "latest"), limit: 1, @@ -413,34 +475,43 @@ async function probeVatDirectSources(filters) { }); let fallbackUsed = false; if (probeResult.error) { + let latestError = probeResult.error; if (isAbortErrorMessage(probeResult.error)) { - return { - fullName: object.fullName, - synonym: object.synonym, - objectType: object.objectType, - status: "error", - rowsFetched: probeResult.fetched_rows, - error: probeResult.error - }; + await sleepMs(VAT_OBJECT_PROBE_ABORT_RETRY_DELAY_MS); + const retryLatestResult = await (0, addressMcpClient_1.executeAddressMcpQuery)({ + query: buildVatObjectProbeQuery(object, asOfExpr, "latest"), + limit: 1, + timeout_ms: VAT_OBJECT_PROBE_ABORT_RETRY_TIMEOUT_MS + }); + if (!retryLatestResult.error) { + probeResult = retryLatestResult; + latestError = null; + } + else { + probeResult = retryLatestResult; + latestError = `${latestError}; retry_latest: ${retryLatestResult.error}`; + } } - const fallbackResult = await (0, addressMcpClient_1.executeAddressMcpQuery)({ - query: buildVatObjectProbeQuery(object, asOfExpr, "exists"), - limit: 1, - timeout_ms: VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS - }); - if (!fallbackResult.error) { - probeResult = fallbackResult; - fallbackUsed = true; - } - else { - return { - fullName: object.fullName, - synonym: object.synonym, - objectType: object.objectType, - status: "error", - rowsFetched: probeResult.fetched_rows, - error: `${probeResult.error}; fallback: ${fallbackResult.error}` - }; + if (probeResult.error) { + const fallbackResult = await (0, addressMcpClient_1.executeAddressMcpQuery)({ + query: buildVatObjectProbeQuery(object, asOfExpr, "exists"), + limit: 1, + timeout_ms: VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS + }); + if (!fallbackResult.error) { + probeResult = fallbackResult; + fallbackUsed = true; + } + else { + return { + fullName: object.fullName, + synonym: object.synonym, + objectType: object.objectType, + status: "error", + rowsFetched: probeResult.fetched_rows, + error: `${latestError ?? probeResult.error}; fallback: ${fallbackResult.error}` + }; + } } } const firstRow = probeResult.raw_rows[0] ?? null; @@ -462,7 +533,13 @@ async function probeVatDirectSources(filters) { sampleRegistrator }; }); - const status = metadataResponses.every((item) => item.error) ? "error" : "ok"; + const hasProbeAttempts = probeRows.length > 0; + const hasNonErrorProbeResult = probeRows.some((item) => item.status !== "error"); + const status = hasProbeAttempts && hasNonErrorProbeResult + ? "ok" + : metadataResponses.every((item) => item.error) && !hasProbeAttempts + ? "error" + : "ok"; const allErrors = [ ...metadataErrors, ...probeRows diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index 1a228d4..370f147 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -2114,7 +2114,10 @@ function composeFactualReply(intent, rows, options = {}) { lines.push("- Сумма расчета выше получена по книгам продаж/покупок; probe использован для контроля полноты VAT-источников."); } else if (vatProbe && vatProbe.status === "error") { - lines.push("", "Покрытие VAT-источников через MCP: probe завершился ошибкой, проверьте доступность регистров книг продаж/покупок."); + lines.push("", "Покрытие VAT-источников через MCP: дополнительный probe недоступен (например, timeout metadata).", "Итоговая сумма НДС выше рассчитана по основному маршруту книг продаж/покупок; probe влияет только на диагностику покрытия."); + if (vatProbe.errors.length > 0) { + lines.push(`- Детали probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`); + } } if (rows.length === 0) { lines.push("", "За выбранный налоговый период не найдены строки книг продаж/покупок, поэтому подтвержденная сумма к уплате равна 0."); diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 624e612..cdac566 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -91,15 +91,20 @@ const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200; const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000; const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000; const VAT_METADATA_PROBE_LIMIT = 100; -const VAT_SOURCE_PROBE_MAX_OBJECTS = 6; -const VAT_METADATA_PROBE_TYPES = ["РегистрНакопления", "Документ"] as const; +const VAT_SOURCE_PROBE_MAX_OBJECTS = 4; +const VAT_METADATA_PROBE_TYPES = ["РегистрНакопления"] as const; const VAT_METADATA_PROBE_MASKS = ["ндс", "книгапродаж", "книгапокупок", "счетфактур"] as const; -const VAT_METADATA_PROBE_CONCURRENCY = 4; -const VAT_METADATA_PROBE_TIMEOUT_MS = 800; -const VAT_METADATA_PROBE_RETRY_TIMEOUT_MS = 1_200; -const VAT_OBJECT_PROBE_CONCURRENCY = 4; -const VAT_OBJECT_PROBE_TIMEOUT_MS = 800; -const VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS = 800; +const VAT_METADATA_PROBE_CONCURRENCY = 2; +const VAT_METADATA_PROBE_STAGGER_MS = 90; +const VAT_METADATA_PROBE_TIMEOUT_MS = 1_200; +const VAT_METADATA_PROBE_RETRY_TIMEOUT_MS = 1_800; +const VAT_METADATA_PROBE_RETRY_DELAY_MS = 140; +const VAT_OBJECT_PROBE_CONCURRENCY = 1; +const VAT_OBJECT_PROBE_STAGGER_MS = 120; +const VAT_OBJECT_PROBE_TIMEOUT_MS = 1_500; +const VAT_OBJECT_PROBE_ABORT_RETRY_TIMEOUT_MS = 2_200; +const VAT_OBJECT_PROBE_ABORT_RETRY_DELAY_MS = 180; +const VAT_OBJECT_PROBE_FALLBACK_TIMEOUT_MS = 1_500; const PARTY_ANCHOR_STOPWORDS = new Set([ "ооо", "ао", @@ -156,6 +161,28 @@ interface VatMetadataObject { synonym: string | null; objectType: "document" | "register"; } +const VAT_FALLBACK_METADATA_OBJECTS: VatMetadataObject[] = [ + { + fullName: "РегистрНакопления.НДСЗаписиКнигиПродаж", + synonym: "НДС Продажи", + objectType: "register" + }, + { + fullName: "РегистрНакопления.НДСЗаписиКнигиПокупок", + synonym: "НДС Покупки", + objectType: "register" + }, + { + fullName: "РегистрНакопления.НДСПредъявленный", + synonym: "НДС предъявленный", + objectType: "register" + }, + { + fullName: "РегистрНакопления.НДСВключенныйВСтоимость", + synonym: "НДС, включенный в стоимость", + objectType: "register" + } +]; const COUNTERPARTY_CATALOG_LOOKUP_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период, @@ -363,6 +390,14 @@ async function mapWithConcurrency( return results; } +function sleepMs(ms: number): Promise { + const delayMs = Number.isFinite(ms) ? Math.max(0, Math.trunc(ms)) : 0; + if (delayMs <= 0) { + return Promise.resolve(); + } + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} + async function executeVatMetadataProbeRequest(request: { meta_type: string; name_mask: string; @@ -376,6 +411,7 @@ async function executeVatMetadataProbeRequest(request: { return firstAttempt; } + await sleepMs(VAT_METADATA_PROBE_RETRY_DELAY_MS); const retryLimit = Math.max(20, Math.min(request.limit, Math.trunc(request.limit / 2))); const retryAttempt = await executeAddressMcpMetadata({ ...request, @@ -420,6 +456,11 @@ function scoreVatMetadataObject(item: VatMetadataObject): number { return score; } +function vatMetadataObjectPriority(item: VatMetadataObject): number { + const idx = VAT_FALLBACK_METADATA_OBJECTS.findIndex((fallback) => fallback.fullName === item.fullName); + return idx >= 0 ? idx : VAT_FALLBACK_METADATA_OBJECTS.length + 1; +} + type VatObjectProbeMode = "latest" | "exists"; function buildVatObjectProbeQuery(object: VatMetadataObject, asOfExpr: string, mode: VatObjectProbeMode = "latest"): string { @@ -494,7 +535,12 @@ async function probeVatDirectSources(filters: AddressFilterSet): Promise executeVatMetadataProbeRequest(request) + async (request, index) => { + if (index > 0 && VAT_METADATA_PROBE_STAGGER_MS > 0) { + await sleepMs(index * VAT_METADATA_PROBE_STAGGER_MS); + } + return executeVatMetadataProbeRequest(request); + } ); const metadataOutcomes = metadataResponses.map((response, index) => ({ @@ -539,14 +585,36 @@ async function probeVatDirectSources(filters: AddressFilterSet): Promise isVatMetadataObject(item)) .sort( - (a, b) => scoreVatMetadataObject(b) - scoreVatMetadataObject(a) || a.fullName.localeCompare(b.fullName, "ru") + (a, b) => + vatMetadataObjectPriority(a) - vatMetadataObjectPriority(b) || + scoreVatMetadataObject(b) - scoreVatMetadataObject(a) || + a.fullName.localeCompare(b.fullName, "ru") ); - const metadataObjects = discoveredMetadataObjects.slice(0, VAT_SOURCE_PROBE_MAX_OBJECTS); + const mergedMetadataObjectsMap = new Map(); + for (const item of discoveredMetadataObjects) { + mergedMetadataObjectsMap.set(item.fullName, item); + } + for (const fallbackObject of VAT_FALLBACK_METADATA_OBJECTS) { + if (!mergedMetadataObjectsMap.has(fallbackObject.fullName)) { + mergedMetadataObjectsMap.set(fallbackObject.fullName, fallbackObject); + } + } + const metadataObjects = Array.from(mergedMetadataObjectsMap.values()) + .sort( + (a, b) => + vatMetadataObjectPriority(a) - vatMetadataObjectPriority(b) || + scoreVatMetadataObject(b) - scoreVatMetadataObject(a) || + a.fullName.localeCompare(b.fullName, "ru") + ) + .slice(0, VAT_SOURCE_PROBE_MAX_OBJECTS); const probeRows = await mapWithConcurrency( metadataObjects, VAT_OBJECT_PROBE_CONCURRENCY, - async (object): Promise => { + async (object, index): Promise => { + if (index > 0 && VAT_OBJECT_PROBE_STAGGER_MS > 0) { + await sleepMs(index * VAT_OBJECT_PROBE_STAGGER_MS); + } let probeResult = await executeAddressMcpQuery({ query: buildVatObjectProbeQuery(object, asOfExpr, "latest"), limit: 1, @@ -554,33 +622,41 @@ async function probeVatDirectSources(filters: AddressFilterSet): Promise item.error) ? "error" : "ok"; + const hasProbeAttempts = probeRows.length > 0; + const hasNonErrorProbeResult = probeRows.some((item) => item.status !== "error"); + const status: VatDirectSourceProbeSummary["status"] = + hasProbeAttempts && hasNonErrorProbeResult + ? "ok" + : metadataResponses.every((item) => item.error) && !hasProbeAttempts + ? "error" + : "ok"; const allErrors = [ ...metadataErrors, ...probeRows diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index e5530c4..ef8043b 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -2718,7 +2718,14 @@ export function composeFactualReply( } lines.push("- Сумма расчета выше получена по книгам продаж/покупок; probe использован для контроля полноты VAT-источников."); } else if (vatProbe && vatProbe.status === "error") { - lines.push("", "Покрытие VAT-источников через MCP: probe завершился ошибкой, проверьте доступность регистров книг продаж/покупок."); + lines.push( + "", + "Покрытие VAT-источников через MCP: дополнительный probe недоступен (например, timeout metadata).", + "Итоговая сумма НДС выше рассчитана по основному маршруту книг продаж/покупок; probe влияет только на диагностику покрытия." + ); + if (vatProbe.errors.length > 0) { + lines.push(`- Детали probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`); + } } if (rows.length === 0) {