From 924f6fb0eab9c96cd8a2d45535079129c735b46e Mon Sep 17 00:00:00 2001 From: dctouch Date: Fri, 1 May 2026 22:37:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BA=D1=80=D0=B5=D0=BF=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20exact=20value-flow=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D1=83=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dist/services/addressFilterExtractor.js | 11 ++++ .../dist/services/addressIntentResolver.js | 14 +++++- .../counterpartyAnalyticsReplyBuilders.js | 21 +++++--- .../dist/services/assistantRoutePolicy.js | 21 +++++++- .../src/services/addressFilterExtractor.ts | 15 ++++++ .../src/services/addressIntentResolver.ts | 18 ++++++- .../counterpartyAnalyticsReplyBuilders.ts | 21 +++++--- .../src/services/assistantRoutePolicy.ts | 23 ++++++++- .../tests/addressQueryRuntimeM23.test.ts | 20 ++++++-- .../addressReplyBuildersRegression.test.ts | 50 ++++++++++++++++++- .../tests/assistantLivingRouter.test.ts | 40 +++++++++++++++ 11 files changed, 234 insertions(+), 20 deletions(-) diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 008696d..68b11b6 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -11,6 +11,7 @@ const iconv_lite_1 = __importDefault(require("iconv-lite")); const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i; const ACCOUNT_REVERSE_PATTERN = /(?:^|[\s,.;:!?()\-])(\d{2}(?:[.,]\d{1,2})?)(?=\s*(?:сч[её]т|счет|account|acct))/iu; const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|первые|топ)[\s\-–—_:№#]*?(\d{1,3})/iu; +const VALUE_ANALYTICS_SAMPLE_LIMIT = 1000; const COUNTERPARTY_PATTERN = /(?:по\s+контрагенту|контрагент(?:у|а)?|по\s+контре|контра|по\s+компан(?:ии|ию|ия)|компан(?:ия|ии|ию)|по\s+организац(?:ии|ию|ия)|организац(?:ия|ии|ию)|по\s+поставщик(?:у|а)?|поставщик(?:у|а)?|по\s+клиент(?:у|а)?|клиент(?:у|а)?|по\s+покупател(?:ю|я)|покупател(?:ю|я)|по\s+партнер(?:у|а)?|партнер(?:у|а)?|by\s+counterparty|counterparty|by\s+company|company|by\s+supplier|supplier|by\s+vendor|vendor|by\s+customer|customer|by\s+client|client|by\s+partner|partner)\s+([^\r\n,.;:]+)/iu; const CONTRACT_PATTERN = /(?:по\s+(?:договору|контракту)|(?:договор|контракт)(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i; const DATE_DMY_PATTERN = /\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/; @@ -1465,6 +1466,11 @@ function buildSemanticFrame(text, filters, warnings) { selected_object_scope_detected: selectedObjectScopeDetected }; } +function shouldExpandSampleForValueAnalytics(intent) { + return (intent === "customer_revenue_and_payments" || + intent === "supplier_payouts_profile" || + intent === "contract_usage_and_value"); +} function extractAddressFilters(userMessage, intent) { const rawText = String(userMessage ?? "").trim(); const text = normalizeMojibakeString(rawText); @@ -1510,6 +1516,11 @@ function extractAddressFilters(userMessage, intent) { filters.limit = Math.min(200, Math.trunc(parsed)); } } + if (shouldExpandSampleForValueAnalytics(intent)) { + const currentLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit) ? Math.max(1, Math.trunc(filters.limit)) : 0; + filters.limit = Math.max(currentLimit, VALUE_ANALYTICS_SAMPLE_LIMIT); + warnings.push("value_analytics_sample_limit_expanded"); + } if (isInventoryItemAnchoredIntent(intent)) { const itemAnchor = extractInventoryItemAnchor(text); if (itemAnchor) { diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 7138b7e..b01f226 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1891,7 +1891,19 @@ function resolveAddressIntent(userMessage) { const currentTurnBridgeText = turnNoiseNormalizedBridgeText !== bridgeText ? `${bridgeText} ${turnNoiseNormalizedBridgeText}` : bridgeText; const unicodeAddressIntent = resolveUnicodeAddressIntentBridge(currentTurnBridgeText); if (unicodeAddressIntent) { - return unicodeAddressIntent; + const reasons = [...unicodeAddressIntent.reasons]; + if (currentTurnBridgeText !== bridgeText && !reasons.includes("current_turn_noise_normalized")) { + reasons.push("current_turn_noise_normalized"); + } + if (unicodeAddressIntent.intent === "customer_revenue_and_payments" && + [text, repairedText, turnNoiseNormalizedBridgeText, currentTurnBridgeText].some((sample) => hasSpecificCounterpartyRevenueBridgeSignal(sample)) && + !reasons.includes("specific_counterparty_revenue_bridge_signal_detected")) { + reasons.push("specific_counterparty_revenue_bridge_signal_detected"); + } + return { + ...unicodeAddressIntent, + reasons + }; } const hasLooseVatPayableBridge = /(?:\u043d\u0434\u0441|vat)/iu.test(text) && /(?:\u043a\u0430\u043a\u043e\u0439\s+\u043d\u0434\u0441\s+(?:(?:\u043d\u0430\u043c|(?:\u043c\u044b\s+)?\u0434\u043e\u043b\u0436\u043d\u044b)\s+)?(?:\u043d\u0430\u0434\u043e|\u043d\u0443\u0436\u043d\u043e|\u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0430\u0434\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0443\u0436\u043d\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043d\u0434\u0441\s+\u043a\s+\u0443\u043f\u043b\u0430\u0442\u0435)/iu.test(text) && diff --git a/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js index e43c7e5..5c4421c 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js @@ -593,15 +593,24 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { return (0, replyContracts_1.buildFactualListReply)(lines); } const visible = rankedByTotal.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} поставщиков по сумме выплат:` - : `Топ-${visible.length} заказчиков по сумме поступлений:`; + const singleCandidateOnly = rankedByTotal.length === 1; + const heading = singleCandidateOnly + ? isSupplier + ? "Найденный поставщик по сумме выплат:" + : "Найденный заказчик по сумме поступлений:" + : isSupplier + ? `Топ-${visible.length} поставщиков по сумме выплат:` + : `Топ-${visible.length} заказчиков по сумме поступлений:`; const leadingCounterparty = visible[0] ?? null; lines.unshift(heading); if (leadingCounterparty) { - const directAnswerLine = isSupplier - ? `Крупнейший поставщик по подтвержденным выплатам за доступное время: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).` - : `Самый доходный клиент за доступное время по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это денежный поток, а не чистая прибыль.`; + const directAnswerLine = singleCandidateOnly + ? isSupplier + ? `В выбранном срезе найден один поставщик: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.` + : `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг; сумма является денежным потоком, а не чистой прибылью.` + : isSupplier + ? `Крупнейший поставщик по подтвержденным выплатам за доступное время: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).` + : `Самый доходный клиент за доступное время по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это денежный поток, а не чистая прибыль.`; lines.unshift(directAnswerLine); } lines.push(...visible.map((item, index) => { diff --git a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js index 889e156..a9b1115 100644 --- a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js @@ -968,6 +968,24 @@ function createAssistantRoutePolicy(deps) { } const metaAnswerFollowupSignal = metaSignals.metaAnswerFollowupSignal; const answerInspectionFollowupSignal = metaSignals.answerInspectionFollowupSignal; + const customerValueRankingAddressSignal = [ + rawUserMessage, + effectiveAddressUserMessage, + repairedRawUserMessage, + repairedEffectiveAddressUserMessage + ].some((value) => { + const normalized = compactWhitespace(repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е"); + if (!normalized) { + return false; + } + if (capabilityMetaQuery || dataScopeMetaQuery) { + return false; + } + const hasRankingCue = /(?:сам(?:ый|ая|ое|ые)|топ|рейтинг|больше\s+всего|максимальн|лидер|highest|top|best)/iu.test(normalized); + const hasValueCue = /(?:доход|выруч|оборот|денег|принес|поступлен|revenue|turnover|value|money)/iu.test(normalized); + const hasCustomerCue = /(?:клиент|покупател|контрагент|customer|counterparty|кто\s+у\s+нас|кто\s+нам|кто\s+больше)/iu.test(normalized); + return hasRankingCue && hasValueCue && hasCustomerCue; + }); const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected && llmPreDecomposeMeta?.applied && llmContractMode === "address_query") || @@ -979,6 +997,7 @@ function createAssistantRoutePolicy(deps) { hasLooseAllTimeAddressLookupSignal(effectiveAddressUserMessage) || hasLooseAllTimeAddressLookupSignal(repairedRawUserMessage) || hasLooseAllTimeAddressLookupSignal(repairedEffectiveAddressUserMessage) || + customerValueRankingAddressSignal || hasAddressFollowupContextSignal(rawUserMessage) || hasAddressFollowupContextSignal(effectiveAddressUserMessage) || hasAddressFollowupContextSignal(repairedRawUserMessage) || @@ -1023,7 +1042,7 @@ function createAssistantRoutePolicy(deps) { resolvedIntentResolution.intent === "unknown" && (!llmContractIntent || llmContractIntent === "unknown")); const exactAddressIntentProtectedFromSemanticDeepHint = laneProtectionArbitration.exactAddressIntentProtectedFromSemanticDeepHint; - const protectAddressLaneFromFallback = laneProtectionArbitration.protectAddressLaneFromFallback; + const protectAddressLaneFromFallback = Boolean(laneProtectionArbitration.protectAddressLaneFromFallback || customerValueRankingAddressSignal); const vatExplainFollowupSignal = Boolean(followupContext && toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" && /(?:\u043f\u043e\u0447\u0435\u043c\u0443|why).*(?:\u043f\u0440\u043e\u0433\u043d\u043e\u0437|forecast).*(?:\u0443\u043f\u043b\u0430\u0442|payable|\b0\b)/iu.test(compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`))); diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 5d10783..e14e4eb 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -5,6 +5,7 @@ const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[ const ACCOUNT_REVERSE_PATTERN = /(?:^|[\s,.;:!?()\-])(\d{2}(?:[.,]\d{1,2})?)(?=\s*(?:сч[её]т|счет|account|acct))/iu; const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|первые|топ)[\s\-–—_:№#]*?(\d{1,3})/iu; +const VALUE_ANALYTICS_SAMPLE_LIMIT = 1000; const COUNTERPARTY_PATTERN = /(?:по\s+контрагенту|контрагент(?:у|а)?|по\s+контре|контра|по\s+компан(?:ии|ию|ия)|компан(?:ия|ии|ию)|по\s+организац(?:ии|ию|ия)|организац(?:ия|ии|ию)|по\s+поставщик(?:у|а)?|поставщик(?:у|а)?|по\s+клиент(?:у|а)?|клиент(?:у|а)?|по\s+покупател(?:ю|я)|покупател(?:ю|я)|по\s+партнер(?:у|а)?|партнер(?:у|а)?|by\s+counterparty|counterparty|by\s+company|company|by\s+supplier|supplier|by\s+vendor|vendor|by\s+customer|customer|by\s+client|client|by\s+partner|partner)\s+([^\r\n,.;:]+)/iu; const CONTRACT_PATTERN = @@ -1705,6 +1706,14 @@ function buildSemanticFrame( }; } +function shouldExpandSampleForValueAnalytics(intent: AddressIntent): boolean { + return ( + intent === "customer_revenue_and_payments" || + intent === "supplier_payouts_profile" || + intent === "contract_usage_and_value" + ); +} + export function extractAddressFilters(userMessage: string, intent: AddressIntent): AddressFilterExtraction { const rawText = String(userMessage ?? "").trim(); const text = normalizeMojibakeString(rawText); @@ -1753,6 +1762,12 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent filters.limit = Math.min(200, Math.trunc(parsed)); } } + if (shouldExpandSampleForValueAnalytics(intent)) { + const currentLimit = + typeof filters.limit === "number" && Number.isFinite(filters.limit) ? Math.max(1, Math.trunc(filters.limit)) : 0; + filters.limit = Math.max(currentLimit, VALUE_ANALYTICS_SAMPLE_LIMIT); + warnings.push("value_analytics_sample_limit_expanded"); + } if (isInventoryItemAnchoredIntent(intent)) { const itemAnchor = extractInventoryItemAnchor(text); diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index 5775802..f86dcdc 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -2768,7 +2768,23 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti const unicodeAddressIntent = resolveUnicodeAddressIntentBridge(currentTurnBridgeText); if (unicodeAddressIntent) { - return unicodeAddressIntent; + const reasons = [...unicodeAddressIntent.reasons]; + if (currentTurnBridgeText !== bridgeText && !reasons.includes("current_turn_noise_normalized")) { + reasons.push("current_turn_noise_normalized"); + } + if ( + unicodeAddressIntent.intent === "customer_revenue_and_payments" && + [text, repairedText, turnNoiseNormalizedBridgeText, currentTurnBridgeText].some((sample) => + hasSpecificCounterpartyRevenueBridgeSignal(sample) + ) && + !reasons.includes("specific_counterparty_revenue_bridge_signal_detected") + ) { + reasons.push("specific_counterparty_revenue_bridge_signal_detected"); + } + return { + ...unicodeAddressIntent, + reasons + }; } const hasLooseVatPayableBridge = diff --git a/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts index 6194406..8aab809 100644 --- a/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts @@ -787,15 +787,24 @@ export function composeCounterpartyAnalyticsReply( } const visible = rankedByTotal.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} поставщиков по сумме выплат:` - : `Топ-${visible.length} заказчиков по сумме поступлений:`; + const singleCandidateOnly = rankedByTotal.length === 1; + const heading = singleCandidateOnly + ? isSupplier + ? "Найденный поставщик по сумме выплат:" + : "Найденный заказчик по сумме поступлений:" + : isSupplier + ? `Топ-${visible.length} поставщиков по сумме выплат:` + : `Топ-${visible.length} заказчиков по сумме поступлений:`; const leadingCounterparty = visible[0] ?? null; lines.unshift(heading); if (leadingCounterparty) { - const directAnswerLine = isSupplier - ? `Крупнейший поставщик по подтвержденным выплатам за доступное время: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).` - : `Самый доходный клиент за доступное время по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это денежный поток, а не чистая прибыль.`; + const directAnswerLine = singleCandidateOnly + ? isSupplier + ? `В выбранном срезе найден один поставщик: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.` + : `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг; сумма является денежным потоком, а не чистой прибылью.` + : isSupplier + ? `Крупнейший поставщик по подтвержденным выплатам за доступное время: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).` + : `Самый доходный клиент за доступное время по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это денежный поток, а не чистая прибыль.`; lines.unshift(directAnswerLine); } lines.push( diff --git a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts index 85d6fa9..5902e8e 100644 --- a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts @@ -1053,6 +1053,24 @@ export function createAssistantRoutePolicy(deps) { } const metaAnswerFollowupSignal = metaSignals.metaAnswerFollowupSignal; const answerInspectionFollowupSignal = metaSignals.answerInspectionFollowupSignal; + const customerValueRankingAddressSignal = [ + rawUserMessage, + effectiveAddressUserMessage, + repairedRawUserMessage, + repairedEffectiveAddressUserMessage + ].some((value) => { + const normalized = compactWhitespace(repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е"); + if (!normalized) { + return false; + } + if (capabilityMetaQuery || dataScopeMetaQuery) { + return false; + } + const hasRankingCue = /(?:сам(?:ый|ая|ое|ые)|топ|рейтинг|больше\s+всего|максимальн|лидер|highest|top|best)/iu.test(normalized); + const hasValueCue = /(?:доход|выруч|оборот|денег|принес|поступлен|revenue|turnover|value|money)/iu.test(normalized); + const hasCustomerCue = /(?:клиент|покупател|контрагент|customer|counterparty|кто\s+у\s+нас|кто\s+нам|кто\s+больше)/iu.test(normalized); + return hasRankingCue && hasValueCue && hasCustomerCue; + }); const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected && llmPreDecomposeMeta?.applied && llmContractMode === "address_query") || @@ -1064,6 +1082,7 @@ export function createAssistantRoutePolicy(deps) { hasLooseAllTimeAddressLookupSignal(effectiveAddressUserMessage) || hasLooseAllTimeAddressLookupSignal(repairedRawUserMessage) || hasLooseAllTimeAddressLookupSignal(repairedEffectiveAddressUserMessage) || + customerValueRankingAddressSignal || hasAddressFollowupContextSignal(rawUserMessage) || hasAddressFollowupContextSignal(effectiveAddressUserMessage) || hasAddressFollowupContextSignal(repairedRawUserMessage) || @@ -1108,7 +1127,9 @@ export function createAssistantRoutePolicy(deps) { resolvedIntentResolution.intent === "unknown" && (!llmContractIntent || llmContractIntent === "unknown")); const exactAddressIntentProtectedFromSemanticDeepHint = laneProtectionArbitration.exactAddressIntentProtectedFromSemanticDeepHint; - const protectAddressLaneFromFallback = laneProtectionArbitration.protectAddressLaneFromFallback; + const protectAddressLaneFromFallback = Boolean( + laneProtectionArbitration.protectAddressLaneFromFallback || customerValueRankingAddressSignal + ); const vatExplainFollowupSignal = Boolean(followupContext && toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" && /(?:\u043f\u043e\u0447\u0435\u043c\u0443|why).*(?:\u043f\u0440\u043e\u0433\u043d\u043e\u0437|forecast).*(?:\u0443\u043f\u043b\u0430\u0442|payable|\b0\b)/iu.test(compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`))); diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 2ad4ead..6c497c4 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -2822,9 +2822,9 @@ describe("address filter extraction for balance drilldown", () => { expect(counterpartyProfile.extracted_filters.limit).toBeUndefined(); expect(counterpartyLifecycle.extracted_filters.limit).toBeUndefined(); expect(contractOverview.extracted_filters.limit).toBeUndefined(); - expect(customerValue.extracted_filters.limit).toBe(20); - expect(supplierValue.extracted_filters.limit).toBe(20); - expect(contractValue.extracted_filters.limit).toBe(20); + expect(customerValue.extracted_filters.limit).toBe(1000); + expect(supplierValue.extracted_filters.limit).toBe(1000); + expect(contractValue.extracted_filters.limit).toBe(1000); expect(vatForecast.extracted_filters.limit).toBeUndefined(); expect(periodProfile.extracted_filters.period_to).toBeDefined(); expect(docSectionProfile.extracted_filters.period_to).toBeDefined(); @@ -2844,6 +2844,9 @@ describe("address filter extraction for balance drilldown", () => { expect(customerValue.warnings).toContain("period_to_defaulted_today_for_management_profile"); expect(supplierValue.warnings).toContain("period_to_defaulted_today_for_management_profile"); expect(contractValue.warnings).toContain("period_to_defaulted_today_for_management_profile"); + expect(customerValue.warnings).toContain("value_analytics_sample_limit_expanded"); + expect(supplierValue.warnings).toContain("value_analytics_sample_limit_expanded"); + expect(contractValue.warnings).toContain("value_analytics_sample_limit_expanded"); expect(vatForecast.warnings).toContain("period_derived_from_month_phrase"); expect(vatForecast.warnings).toContain("period_from_derived_from_quarter_for_vat_forecast"); expect(vatForecast.warnings).toContain("period_to_derived_from_as_of_date_for_vat_forecast"); @@ -5066,6 +5069,17 @@ describe("address recipe catalog counterparty filtering", () => { expect(plan.query).toContain("БанкПоступление.ДоговорКонтрагента"); }); + it("expands customer value analytics sample independently from visible ranking size", () => { + const filters = extractAddressFilters("какой у нас самый доходный год", "customer_revenue_and_payments"); + const selected = selectAddressRecipe("customer_revenue_and_payments", filters.extracted_filters); + expect(selected.selected_recipe).toBeTruthy(); + const plan = buildAddressRecipePlan(selected.selected_recipe!, filters.extracted_filters); + + expect(filters.extracted_filters.limit).toBe(1000); + expect(filters.warnings).toContain("value_analytics_sample_limit_expanded"); + expect(plan.limit).toBe(1000); + }); + it("selects supplier payouts recipe and keeps top-20 default", () => { const selected = selectAddressRecipe("supplier_payouts_profile", {}); expect(selected.selected_recipe).toBeTruthy(); diff --git a/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts b/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts index f7c0c60..d06a6b5 100644 --- a/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts +++ b/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts @@ -9,9 +9,16 @@ describe("address reply builders regressions", () => { "customer_revenue_and_payments", [ { + counterparty: "Чапурнов", amount: 250000, period: "2020-03-31", registrator: "Поступление 1" + } as any, + { + counterparty: "Малый клиент", + amount: 100000, + period: "2020-04-30", + registrator: "Поступление 2" } as any ], { @@ -31,7 +38,7 @@ describe("address reply builders regressions", () => { detectContractValueFocus: () => "top_by_turnover", detectMinOpsForAvgCheck: () => 1, extractRequestedYearFromQuestion: () => null, - extractCounterpartyName: () => "Чапурнов", + extractCounterpartyName: (row: any) => row.counterparty ?? "Чапурнов", extractContractName: () => null, counterpartyLookupMatches: () => false, toUtcDayTimestamp: () => null, @@ -44,6 +51,47 @@ describe("address reply builders regressions", () => { expect(result?.text.split("\n")[0]).toContain("Чапурнов"); }); + it("does not overclaim a comparative top customer ranking when only one candidate is present", () => { + const result = composeCounterpartyAnalyticsReply( + "customer_revenue_and_payments", + [ + { + amount: 250000, + period: "2021-03-31", + registrator: "Поступление 1" + } as any + ], + { + userMessage: "кто больше всего принес денег в 2021" + }, + { + formatPercent: () => null, + formatDateRu: (value: string) => value, + formatMoneyRub: (value: number) => `${value} ₽`, + extractYearFromIso: (value: string | null) => (value ? Number(value.slice(0, 4)) : null), + detectCounterpartyProfileFocus: () => "full_profile", + detectCounterpartyLifecycleFocus: () => "active_customers_all_time", + hasCounterpartyLifecycleLongevityQuestion: () => false, + hasCounterpartyActivityAgeQuestion: () => false, + detectRankingLimit: () => 5, + detectValueRankingFocus: () => "top_by_total", + detectContractValueFocus: () => "top_by_turnover", + detectMinOpsForAvgCheck: () => 1, + extractRequestedYearFromQuestion: () => null, + extractCounterpartyName: () => "Группа СВК", + extractContractName: () => null, + counterpartyLookupMatches: () => false, + toUtcDayTimestamp: () => null, + formatAgeYearsMonthsDays: () => "0 дней", + normalizeQuestionText: (value: string | null | undefined) => String(value ?? "") + } + ); + + expect(result?.text.split("\n")[0]).toContain("найден один клиент"); + expect(result?.text.split("\n")[0]).toContain("не полноценный сравнительный рейтинг"); + expect(result?.text).not.toContain("Самый доходный клиент"); + }); + it("starts top year aggregate reply with a direct business answer", () => { const result = composeCounterpartyAnalyticsReply( "customer_revenue_and_payments", diff --git a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts index b1fc07a..6f5df43 100644 --- a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts @@ -761,6 +761,46 @@ describe("assistant orchestration contract", () => { expect(decision.orchestrationContract?.aggregate_analytics_signal_fallback_to_deep).toBe(false); }); + it("keeps all-time top customer ranking in address lane instead of stale deep problem answer", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "кто у нас самый доходный клиент за все время", + effectiveAddressUserMessage: "определить самого доходного клиента за весь период", + followupContext: { + previous_intent: "counterparty_activity_lifecycle", + previous_filters: { + organization: "ООО Альтернатива Плюс" + } + }, + llmPreDecomposeMeta: { + applied: true, + llmCanonicalCandidateDetected: true, + predecomposeContract: { + mode: "address_query", + mode_confidence: "medium", + intent: "customer_revenue_and_payments", + intent_confidence: "medium" + }, + semanticExtractionContract: { + valid: false, + apply_canonical_recommended: false, + extraction: { + query_shape: "AGGREGATE_LOOKUP", + aggregation_profile: "management_profile" + }, + guard_hints: {}, + reason_codes: ["ranking_semantic_guard_rejected"] + } + } as any, + useMock: false + } as any); + + expect(decision.runAddressLane).toBe(true); + expect(decision.toolGateDecision).toBe("run_address_lane"); + expect(decision.livingMode).toBe("address_data"); + expect(decision.livingReason).toBe("address_lane_triggered"); + expect(decision.orchestrationContract?.aggregate_analytics_signal_fallback_to_deep).toBe(false); + }); + it("keeps unsupported retrieval query in address lane when LLM runtime is unavailable", () => { const decision = resolveAssistantOrchestrationDecision({ rawUserMessage: