diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index ad18bfa..bed3d43 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -808,6 +808,7 @@ function isInventoryTraceIntent(intent) { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date"); } @@ -816,6 +817,7 @@ function isInventoryItemAnchoredIntent(intent) { intent === "inventory_purchase_documents_for_item" || intent === "inventory_aging_by_purchase_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain"); } function usesRecipeDefaultLimit(intent) { @@ -824,6 +826,7 @@ function usesRecipeDefaultLimit(intent) { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date"); } @@ -1197,6 +1200,7 @@ function requiredFiltersByIntent(intent) { } if (intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_sale_trace_for_item" || intent === "inventory_purchase_to_sale_chain") { return ["item"]; @@ -1234,6 +1238,7 @@ function usesAsOfPrimaryWindow(intent) { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date" || intent === "open_items_by_counterparty_or_contract" || diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 69ca944..011fbeb 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1357,6 +1357,9 @@ function hasSelectedObjectInventoryPurchaseDocumentsSignal(text) { function hasSelectedObjectInventorySaleTraceSignal(text) { return hasSelectedObjectInventoryCue(text) && (0, inventoryLifecycleCueHelpers_1.hasInventorySaleCue)(text); } +function hasSelectedObjectInventoryProfitabilitySignal(text) { + return hasSelectedObjectInventoryCue(text) && (0, inventoryLifecycleCueHelpers_1.hasInventoryProfitabilityCue)(text); +} function hasInventoryProvenanceSignalV2(text) { const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:ок|ки)|склад)/iu.test(text); const hasSupplierCue = (0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(text) || /кем\s+поставлен/iu.test(text); @@ -1593,6 +1596,13 @@ function resolveAddressIntent(userMessage) { reasons: ["inventory_purchase_documents_signal_detected"] }; } + if (hasSelectedObjectInventoryProfitabilitySignal(text)) { + return { + intent: "inventory_profitability_for_item", + confidence: "medium", + reasons: ["inventory_selected_object_profitability_signal_detected"] + }; + } if (hasSelectedObjectInventorySaleTraceSignal(text)) { return { intent: "inventory_sale_trace_for_item", diff --git a/llm_normalizer/backend/dist/services/addressNavigationState.js b/llm_normalizer/backend/dist/services/addressNavigationState.js index 54bd10f..962ba38 100644 --- a/llm_normalizer/backend/dist/services/addressNavigationState.js +++ b/llm_normalizer/backend/dist/services/addressNavigationState.js @@ -27,6 +27,7 @@ const DISPLAY_ENTITY_TYPE_BY_INTENT = { inventory_purchase_documents_for_item: "item", inventory_supplier_stock_overlap_as_of_date: "item", inventory_sale_trace_for_item: "item", + inventory_profitability_for_item: "item", inventory_purchase_to_sale_chain: "item", inventory_aging_by_purchase_date: "item" }; @@ -51,6 +52,7 @@ const RESULT_SET_TYPE_BY_INTENT = { inventory_purchase_documents_for_item: "inventory_trace", inventory_supplier_stock_overlap_as_of_date: "inventory_trace", inventory_sale_trace_for_item: "inventory_trace", + inventory_profitability_for_item: "inventory_trace", inventory_purchase_to_sale_chain: "inventory_trace", inventory_aging_by_purchase_date: "inventory_trace", period_coverage_profile: "profile_summary", diff --git a/llm_normalizer/backend/dist/services/addressQueryClassifier.js b/llm_normalizer/backend/dist/services/addressQueryClassifier.js index 8e135e9..1b9fd47 100644 --- a/llm_normalizer/backend/dist/services/addressQueryClassifier.js +++ b/llm_normalizer/backend/dist/services/addressQueryClassifier.js @@ -279,6 +279,7 @@ function hasSelectedObjectInventoryFollowupSignal(text) { return false; } return ((0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(text) || + (0, inventoryLifecycleCueHelpers_1.hasInventoryProfitabilityCue)(text) || (0, inventoryLifecycleCueHelpers_1.hasInventorySaleCue)(text) || /(?:кто\s+(?:поставил|продал)|по\s+каким\s+документам\s+.*купили)/iu.test(text) || /(?:к[оа]му|куда)[\s\S]{0,80}(?:поставил|поставили|поставлен|поставлена|поставлено|отгрузил|отгрузили|отгружен|отгружена|отгружено)/iu.test(text) || diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 2f86037..e2b361b 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -1239,6 +1239,7 @@ function isOrganizationScopedInventoryIntent(intent) { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date"); } @@ -1925,6 +1926,7 @@ function normalizeMissingAnchorLabel(anchor) { } function buildLimitedScopeLine(filters) { const organization = toNonEmptyFilterValue(filters.organization); + const item = toNonEmptyFilterValue(filters.item); const asOfDate = toNonEmptyFilterValue(filters.as_of_date); const periodFrom = toNonEmptyFilterValue(filters.period_from); const periodTo = toNonEmptyFilterValue(filters.period_to); @@ -1932,6 +1934,9 @@ function buildLimitedScopeLine(filters) { if (organization) { scopeParts.push(`организация ${organization}`); } + if (item) { + scopeParts.push(`товар ${item}`); + } if (asOfDate) { scopeParts.push(`срез на ${asOfDate}`); } @@ -1951,6 +1956,7 @@ function buildLimitedVariantSeedFingerprint(filters) { "contract", "account", "document_ref", + "item", "as_of_date", "period_from", "period_to" @@ -2001,6 +2007,9 @@ function buildLimitedOffers(input) { else if (input.intent === "inventory_sale_trace_for_item") { offers.push("показать подтвержденные движения выбытия товара со счета 41.01"); } + else if (input.intent === "inventory_profitability_for_item") { + offers.push("показать выручку, прибыль или маржу по выбранному товару за период продаж"); + } else if (input.intent === "inventory_purchase_to_sale_chain") { offers.push("показать документальную цепочку по товару: поступление на 41.01 и последующее выбытие"); } @@ -2060,6 +2069,7 @@ function buildLimitedIntentSignalLine(input) { list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.", list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.", inventory_on_hand_as_of_date: "Сигнал запроса: нужен подтвержденный срез товаров на складе на дату.", + inventory_profitability_for_item: "Сигнал запроса: нужен расчет выручки/прибыли/маржи по выбранной номенклатуре.", receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.", payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату.", vat_payable_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез НДС к уплате на дату.", @@ -2083,6 +2093,7 @@ function hasAggregateLimitedSignal(input) { input.intent === "contract_usage_overview" || input.intent === "supplier_payouts_profile" || input.intent === "customer_revenue_and_payments" || + input.intent === "inventory_profitability_for_item" || input.intent === "contract_usage_and_value") { return true; } diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index c0502ef..6c5ee9f 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -2,6 +2,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.hasInventorySupplierFollowupCue = hasInventorySupplierFollowupCue; exports.hasInventoryPurchaseDocumentsFollowupCue = hasInventoryPurchaseDocumentsFollowupCue; +exports.hasInventoryProfitabilityFollowupCue = hasInventoryProfitabilityFollowupCue; exports.hasInventoryPurchaseDateFollowupCue = hasInventoryPurchaseDateFollowupCue; exports.hasBareInventoryPurchaseDateFollowupCue = hasBareInventoryPurchaseDateFollowupCue; exports.hasInventorySaleFollowupCue = hasInventorySaleFollowupCue; @@ -261,6 +262,7 @@ function isInventoryIntent(intent) { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date"); } @@ -271,6 +273,7 @@ function isInventoryDrilldownFrameIntent(intent) { return (intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date"); } @@ -448,6 +451,9 @@ function hasInventoryPurchaseDocumentsFollowupCue(text) { return (/(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(value) || /(?:(?:покажи|показать|выведи|дай)?[\s\S]{0,30}док(?:и|умент[а-яё]*)[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}док(?:и|умент[а-яё]*))/iu.test(value)); } +function hasInventoryProfitabilityFollowupCue(text) { + return (0, inventoryLifecycleCueHelpers_1.hasInventoryProfitabilityCue)(String(text ?? "")); +} function hasInventoryPurchaseDateFollowupCue(text) { const value = String(text ?? ""); return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test(value) || (/когда/iu.test(value) && (0, inventoryLifecycleCueHelpers_1.hasInventoryPurchaseStem)(value)); @@ -622,6 +628,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date" || intent === "payables_confirmed_as_of_date" || @@ -650,6 +657,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { if ((intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date")) { const inheritedItem = previousItem ?? previousAnchorItem; @@ -874,6 +882,7 @@ function resolveMissingRequiredFilters(intent, filters) { account_balance_snapshot: ["account", "as_of_date"], documents_forming_balance: ["account", "as_of_date"], inventory_on_hand_as_of_date: ["as_of_date"], + inventory_profitability_for_item: ["item"], open_contracts_confirmed_as_of_date: ["as_of_date"], payables_confirmed_as_of_date: ["as_of_date"], receivables_confirmed_as_of_date: ["as_of_date"], @@ -975,6 +984,23 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo }; } } + if (inventorySelectedObjectFollowup && hasInventoryProfitabilityFollowupCue(normalizedMessage)) { + if (detectedIntent.intent === "unknown" || + detectedIntent.intent === "customer_revenue_and_payments" || + detectedIntent.intent === "list_documents_by_counterparty" || + detectedIntent.intent === "list_documents_by_contract" || + detectedIntent.intent === "bank_operations_by_counterparty" || + detectedIntent.intent === "bank_operations_by_contract" || + detectedIntent.intent === "inventory_on_hand_as_of_date" || + detectedIntent.intent === "inventory_sale_trace_for_item" || + detectedIntent.intent === previousIntent) { + return { + intent: "inventory_profitability_for_item", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"] + }; + } + } if (inventorySelectedObjectFollowup && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) { if (detectedIntent.intent === "unknown" || detectedIntent.intent === "inventory_purchase_provenance_for_item" || diff --git a/llm_normalizer/backend/dist/services/address_runtime/resolveStage.js b/llm_normalizer/backend/dist/services/address_runtime/resolveStage.js index 6d5f649..eecc71c 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/resolveStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/resolveStage.js @@ -199,6 +199,7 @@ function resolvePrimaryAnchor(intent, filters) { if ((intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date") && item) { diff --git a/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js index b455fae..d303cc2 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js @@ -2,14 +2,17 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.buildAssistantAddressOrchestrationRuntime = buildAssistantAddressOrchestrationRuntime; const assistantRoutePolicyRuntimeAdapter_1 = require("./assistantRoutePolicyRuntimeAdapter"); +const inventoryLifecycleCueHelpers_1 = require("./inventoryLifecycleCueHelpers"); function hasSelectedObjectInventorySignal(text) { return /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ним|selected\s+object)/iu.test(String(text ?? "")); } function hasSelectedObjectInventoryActionCue(text) { - return /(?:кому[\s\S]{0,80}(?:продал[аи]?|реализова[нлт][а-я]*|поставил[аи]?|поставлен[а-я]*|отгрузил[аи]?|отгружен[а-я]*)|кому\s+был\s+продан|куда[\s\S]{0,80}(?:продал[аи]?|реализова[нлт][а-я]*|поставил[аи]?|поставлен[а-я]*|отгрузил[аи]?|отгружен[а-я]*)|кто[\s\S]{0,40}купил|кто\s+это\s+поставил|кто\s+поставил|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+мы\s+купили|где\s+куплено|по\s+каким\s+документам|какими\s+документами|покажи\s+документы|документы[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|документы\s+закупки|buyer|sale\s+trace|supplier|vendor|purchase\s+documents|purchase[\s-]?to[\s-]?sale|old\s+purchase|aged\s+stock)/iu.test(String(text ?? "")); + const value = String(text ?? ""); + return (/(?:кому[\s\S]{0,80}(?:продал[аи]?|реализова[нлт][а-я]*|поставил[аи]?|поставлен[а-я]*|отгрузил[аи]?|отгружен[а-я]*)|кому\s+был\s+продан|куда[\s\S]{0,80}(?:продал[аи]?|реализова[нлт][а-я]*|поставил[аи]?|поставлен[а-я]*|отгрузил[аи]?|отгружен[а-я]*)|кто[\s\S]{0,40}купил|кто\s+это\s+поставил|кто\s+поставил|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+мы\s+купили|где\s+куплено|по\s+каким\s+документам|какими\s+документами|покажи\s+документы|документы[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|документы\s+закупки|buyer|sale\s+trace|supplier|vendor|purchase\s+documents|purchase[\s-]?to[\s-]?sale|old\s+purchase|aged\s+stock)/iu.test(value) || (0, inventoryLifecycleCueHelpers_1.hasInventoryProfitabilityCue)(value)); } function isGenericCanonicalDriftIntent(intent) { return (intent === "open_items_by_counterparty_or_contract" || + intent === "customer_revenue_and_payments" || intent === "list_documents_by_counterparty" || intent === "list_documents_by_contract" || intent === "bank_operations_by_counterparty" || diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index f756850..f59c05d 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -2505,6 +2505,7 @@ function isInventoryDrilldownFrameIntent(intent) { return intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date"; } @@ -2781,6 +2782,7 @@ function isInventorySelectedObjectIntent(intent) { return intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain"; } function hasShortInventoryObjectFollowupSignal(userMessage) { @@ -3103,6 +3105,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes sourceIntentHint === "inventory_purchase_provenance_for_item" || sourceIntentHint === "inventory_purchase_documents_for_item" || sourceIntentHint === "inventory_sale_trace_for_item" || + sourceIntentHint === "inventory_profitability_for_item" || sourceIntentHint === "inventory_purchase_to_sale_chain" || sourceIntentHint === "inventory_aging_by_purchase_date" || hasSelectedObjectInventorySignalPrimary || @@ -3534,6 +3537,7 @@ function resolveRequiredAnchorTypeForIntent(intent) { if (intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date") { return "item"; @@ -3586,7 +3590,9 @@ function hasSelectedObjectInventoryFollowupSignalForPredecompose(text) { return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(text ?? "")); } function isInventorySelectedObjectFollowupIntent(intent) { - return intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item"; + return intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_profitability_for_item"; } function hasSameDateAccountFollowupSignalForPredecompose(text) { const source = String(text ?? ""); @@ -4269,6 +4275,7 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ "inventory_purchase_documents_for_item", "inventory_supplier_stock_overlap_as_of_date", "inventory_sale_trace_for_item", + "inventory_profitability_for_item", "inventory_purchase_to_sale_chain", "inventory_aging_by_purchase_date", "contract_usage_overview", @@ -4281,6 +4288,7 @@ const ADDRESS_INTENTS_ALLOW_STRICT_DEEP_INVESTIGATION_BYPASS = new Set([ "inventory_purchase_provenance_for_item", "inventory_purchase_documents_for_item", "inventory_sale_trace_for_item", + "inventory_profitability_for_item", "inventory_purchase_to_sale_chain" ]); function shouldBypassStrictDeepInvestigationCueForAddressIntent(intent) { diff --git a/llm_normalizer/backend/dist/services/inventoryLifecycleCueHelpers.js b/llm_normalizer/backend/dist/services/inventoryLifecycleCueHelpers.js index ee53209..7e65c9e 100644 --- a/llm_normalizer/backend/dist/services/inventoryLifecycleCueHelpers.js +++ b/llm_normalizer/backend/dist/services/inventoryLifecycleCueHelpers.js @@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.hasInventoryPurchaseStem = hasInventoryPurchaseStem; exports.hasInventorySupplierCue = hasInventorySupplierCue; exports.hasInventorySaleCue = hasInventorySaleCue; +exports.hasInventoryProfitabilityCue = hasInventoryProfitabilityCue; function toText(value) { return String(value ?? ""); } @@ -34,3 +35,15 @@ function hasInventorySaleCue(text) { } return /(?:^|[\s,.;:!?])(продано|продали|продан(?:а|о|ы)?|реализовано|реализовали|реализован(?:а|о|ы)?)(?=$|[\s,.;:!?])/iu.test(value); } +function hasInventoryProfitabilityCue(text) { + const value = toText(text); + const hasExplicitEconomicsMetric = /(?:прибыл|марж|рентабел|наценк|выручк|доход|profit(?:ability)?|margin|revenue|unit\s+economics)/iu.test(value); + if (hasExplicitEconomicsMetric) { + return true; + } + if (/(?:заработ(?:ал|али|аем|ок|ан)|прин[её]с(?:ли)?)/iu.test(value) && + /(?:денег|деньг|с\s+продаж[а-яё]*|по\s+продаж[а-яё]*|от\s+продаж[а-яё]*|продаж[а-яё]*|реализац|sale|sales)/iu.test(value)) { + return true; + } + return /(?:сколько|скока|скок)[\s\S]{0,60}(?:заработ|прин[её]с|денег[\s\S]{0,20}(?:с\s+продаж|по\s+продаж|от\s+продаж|продаж|реализац))/iu.test(value); +} diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 1d67dab..2531b4c 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -916,6 +916,7 @@ function isInventoryTraceIntent(intent: AddressIntent): boolean { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date" ); @@ -927,6 +928,7 @@ function isInventoryItemAnchoredIntent(intent: AddressIntent): boolean { intent === "inventory_purchase_documents_for_item" || intent === "inventory_aging_by_purchase_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" ); } @@ -938,6 +940,7 @@ function usesRecipeDefaultLimit(intent: AddressIntent): boolean { intent === "inventory_purchase_documents_for_item" || intent === "inventory_supplier_stock_overlap_as_of_date" || intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date" ); @@ -1373,6 +1376,7 @@ function requiredFiltersByIntent(intent: AddressIntent): Array ({ + executeAddressMcpQueryMock: vi.fn() +})); + +vi.mock("../src/services/addressMcpClient", async () => { + const actual = await vi.importActual( + "../src/services/addressMcpClient" + ); + return { + ...actual, + executeAddressMcpQuery: executeAddressMcpQueryMock + }; +}); + +import { AddressQueryService } from "../src/services/addressQueryService"; +import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage"; + +afterEach(() => { + executeAddressMcpQueryMock.mockReset(); + vi.restoreAllMocks(); +}); + +describe("inventory profitability selected-object regressions", () => { + const followupContext = { + previous_intent: "inventory_on_hand_as_of_date" as const, + previous_filters: { + organization: "ООО \\Альтернатива Плюс\\", + as_of_date: "2020-05-31", + period_from: "2020-05-01", + period_to: "2020-05-31" + }, + previous_anchor_type: "unknown" as const, + previous_anchor_value: null + }; + + const selectedObjectProfitabilityMessage = + 'По выбранному объекту "Четки Пост (84*117)": а сколько денег мы заработали с продажжи этих четок'; + + it("routes selected-object profitability wording into an item profitability intent", () => { + const result = runAddressDecomposeStage(selectedObjectProfitabilityMessage, followupContext); + + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("inventory_profitability_for_item"); + expect(result?.intent.intent).not.toBe("customer_revenue_and_payments"); + expect(result?.filters.extracted_filters.item).toBe("Четки Пост (84*117)"); + expect(result?.filters.extracted_filters.period_from).toBe("2020-05-01"); + expect(result?.filters.extracted_filters.period_to).toBe("2020-05-31"); + }); + + it("returns a truthful recipe visibility gap until item profitability gets a dedicated recipe", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle(selectedObjectProfitabilityMessage, { + followupContext + }); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("LIMITED_WITH_REASON"); + expect(result?.debug.detected_intent).toBe("inventory_profitability_for_item"); + expect(result?.debug.selected_recipe).toBeNull(); + expect(result?.debug.limited_reason_category).toBe("recipe_visibility_gap"); + expect(result?.debug.extracted_filters?.item).toBe("Четки Пост (84*117)"); + expect(String(result?.reply_text ?? "")).toContain("Четки Пост (84*117)"); + expect(executeAddressMcpQueryMock).not.toHaveBeenCalled(); + }); +}); diff --git a/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts index 4f19ad4..e6e342b 100644 --- a/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts @@ -377,4 +377,68 @@ describe("assistant address orchestration runtime adapter", () => { }) ); }); + + it("prefers raw selected-object profitability wording over customer revenue canonical drift", async () => { + const resolveAddressFollowupCarryoverContext = vi.fn(() => ({ + followupContext: { + previous_intent: "inventory_on_hand_as_of_date", + previous_filters: { + as_of_date: "2020-05-31", + period_from: "2020-05-01", + period_to: "2020-05-31" + } + } + })); + const resolveAssistantOrchestrationDecision = vi.fn(() => ({ + runAddressLane: true, + livingMode: "address_data", + livingReason: "address_lane_triggered", + toolGateDecision: "run_address_lane", + toolGateReason: "address_mode_classifier_detected", + orchestrationContract: { schema_version: "assistant_orchestration_contract_v1" } + })); + const buildAddressLlmPredecomposeContractV1 = vi.fn(({ sourceMessage, canonicalMessage }: { sourceMessage: string; canonicalMessage: string }) => ({ + schema_version: "address_llm_predecompose_contract_v1", + source_message: sourceMessage, + canonical_message: canonicalMessage, + mode: "address_query", + intent: "unknown" + })); + + const rawMessage = + 'По выбранному объекту "Четки Пост (84*117)": а сколько денег мы заработали с продажжи этих четок'; + + const output = await buildAssistantAddressOrchestrationRuntime( + buildInput({ + userMessage: rawMessage, + runAddressLlmPreDecompose: vi.fn(async () => ({ + attempted: true, + applied: true, + effectiveMessage: "Покажи выручку по контрагенту по выбранной позиции", + reason: "normalized_fragment_applied", + predecomposeContract: { + mode: "address_query", + intent: "customer_revenue_and_payments", + semantics: { + selected_object_scope_detected: true + } + } + })), + buildAddressLlmPredecomposeContractV1, + resolveAddressFollowupCarryoverContext, + resolveAssistantOrchestrationDecision + }) + ); + + expect(output.addressInputMessage).toBe(rawMessage); + expect(output.addressPreDecompose.applied).toBe(false); + expect(output.addressPreDecompose.reason).toBe("followup_raw_message_preferred_over_llm_rewrite"); + expect(resolveAddressFollowupCarryoverContext).toHaveBeenCalledTimes(2); + expect(resolveAssistantOrchestrationDecision).toHaveBeenCalledWith( + expect.objectContaining({ + rawUserMessage: rawMessage, + effectiveAddressUserMessage: rawMessage + }) + ); + }); });