diff --git a/docs/orchestration/agent_margin_profitability_reliability_20260524.json b/docs/orchestration/agent_margin_profitability_reliability_20260524.json index e3391d4..5ac256e 100644 --- a/docs/orchestration/agent_margin_profitability_reliability_20260524.json +++ b/docs/orchestration/agent_margin_profitability_reliability_20260524.json @@ -47,6 +47,7 @@ "period_or_honest_period_clarification", "revenue_cogs_gross_profit_margin_or_honest_unknown", "next_action_if_limited", + "selected_object_followup_keeps_item_focus", "detector_results_fail_or_review_on_real_defect" ], "must_not_have": [ @@ -136,6 +137,86 @@ "forbidden_answer_patterns": [ "(?i)(route_id|capability_id|runtime_|debug|амортизац|объект ОС)" ] + }, + { + "step_id": "step_03_selected_item_supplier", + "title": "Selected item supplier follow-up", + "question": "А по этой позиции кто поставщик?", + "depends_on": [ + "step_02" + ], + "semantic_tags": [ + "margin_profitability", + "selected_object", + "supplier_followup" + ], + "expected_intents": [ + "inventory_purchase_provenance_for_item" + ], + "expected_capability": "inventory_inventory_purchase_provenance_for_item", + "expected_recipe": "address_inventory_purchase_provenance_for_item_v1", + "expected_result_mode": "selected_object_purchase_provenance_or_honest_boundary", + "expected_business_answer_contract": "margin_profitability_v1", + "required_answer_shape": "direct_answer_first", + "required_answer_patterns_any": [ + "(?i)(поставщик|закуп|поступлен|приход|не найден|не подтвержд)" + ], + "forbidden_answer_patterns": [ + "(?i)(route_id|capability_id|runtime_|debug|амортизац|объект ОС)" + ] + }, + { + "step_id": "step_04_selected_item_purchase_date", + "title": "Selected item purchase date follow-up", + "question": "Когда ее купили?", + "depends_on": [ + "step_03_selected_item_supplier" + ], + "semantic_tags": [ + "margin_profitability", + "selected_object", + "purchase_date_followup" + ], + "expected_intents": [ + "inventory_purchase_provenance_for_item" + ], + "expected_capability": "inventory_inventory_purchase_provenance_for_item", + "expected_recipe": "address_inventory_purchase_provenance_for_item_v1", + "expected_result_mode": "selected_object_purchase_date_or_honest_boundary", + "expected_business_answer_contract": "margin_profitability_v1", + "required_answer_shape": "direct_answer_first", + "required_answer_patterns_any": [ + "(?i)(купил|закуп|поступлен|дата|первая|последняя|не найден|не подтвержд)" + ], + "forbidden_answer_patterns": [ + "(?i)(route_id|capability_id|runtime_|debug|амортизац|объект ОС)" + ] + }, + { + "step_id": "step_05_selected_item_documents", + "title": "Selected item document follow-up", + "question": "Каким документом это подтверждено?", + "depends_on": [ + "step_04_selected_item_purchase_date" + ], + "semantic_tags": [ + "margin_profitability", + "selected_object", + "purchase_document_followup" + ], + "expected_intents": [ + "inventory_purchase_provenance_for_item", + "inventory_purchase_documents_for_item" + ], + "expected_result_mode": "selected_object_purchase_documents_or_honest_boundary", + "expected_business_answer_contract": "margin_profitability_v1", + "required_answer_shape": "direct_answer_first", + "required_answer_patterns_any": [ + "(?i)(документ|поступлен|регистратор|накладн|не найден|не подтвержд)" + ], + "forbidden_answer_patterns": [ + "(?i)(route_id|capability_id|runtime_|debug|амортизац|объект ОС)" + ] } ] }, @@ -190,6 +271,32 @@ "(?i)(route_id|capability_id|runtime_|debug)" ], "notes": "Даже если пользователь сам сказал `не ОС`, runtime должен удержать товарный контур и не строить ответ вокруг амортизации." + }, + { + "step_id": "step_03_selected_item_after_fixed_asset_trap", + "title": "Selected item follow-up after fixed asset trap", + "question": "По этой позиции покажи закупочный документ.", + "depends_on": [ + "step_02_fixed_asset_source_trap" + ], + "semantic_tags": [ + "margin_profitability", + "selected_object", + "fixed_asset_trap_followup" + ], + "expected_intents": [ + "inventory_purchase_provenance_for_item", + "inventory_purchase_documents_for_item" + ], + "expected_result_mode": "selected_object_purchase_documents_or_honest_boundary", + "expected_business_answer_contract": "margin_profitability_v1", + "required_answer_shape": "direct_answer_first", + "required_answer_patterns_any": [ + "(?i)(документ|поступлен|закуп|поставщик|не найден|не подтвержд)" + ], + "forbidden_answer_patterns": [ + "(?i)(route_id|capability_id|runtime_|debug|амортизац|объект ОС)" + ] } ] } diff --git a/llm_normalizer/backend/dist/services/addressNavigationState.js b/llm_normalizer/backend/dist/services/addressNavigationState.js index 1b6593f..d3fa2e1 100644 --- a/llm_normalizer/backend/dist/services/addressNavigationState.js +++ b/llm_normalizer/backend/dist/services/addressNavigationState.js @@ -29,6 +29,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_margin_ranking_for_nomenclature: "item", inventory_profitability_for_item: "item", inventory_purchase_to_sale_chain: "item", inventory_aging_by_purchase_date: "item" @@ -55,6 +56,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_margin_ranking_for_nomenclature: "inventory_trace", inventory_profitability_for_item: "inventory_trace", inventory_purchase_to_sale_chain: "inventory_trace", inventory_aging_by_purchase_date: "inventory_trace", @@ -166,7 +168,8 @@ function parseEntityCandidateFromLine(line) { const afterNumber = String(numberedMatch[2] ?? ""); const pieces = afterNumber.split("|").map((item) => item.trim()).filter(Boolean); const valueCandidate = pieces.length > 0 ? pieces[0] : afterNumber; - const cleaned = valueCandidate.replace(/^["'«»“”„`’‘]+|["'«»“”„`’‘]+$/gu, "").trim(); + const businessLabel = valueCandidate.split(/\s+[—–-]\s+(?=(?:выручка|сумма|количество|стоимость|маржа|себестоим|дата|склад))/iu)[0] ?? valueCandidate; + const cleaned = businessLabel.replace(/^["'«»“”„`’‘]+|["'«»“”„`’‘]+$/gu, "").trim(); if (!cleaned || cleaned.length < 2) { return null; } @@ -387,6 +390,30 @@ function buildFocusObjectFromDebug(debug, resultSetId, createdAt) { toNonEmptyString(extractedFilters.item); return rawValue ? buildFocusObject(canonicalType, rawValue, resultSetId, createdAt) : null; } +function buildFocusObjectFromPrimaryEntityRef(resultSet, createdAt) { + if (resultSet.intent !== "inventory_margin_ranking_for_nomenclature") { + return null; + } + const primaryEntity = resultSet.entity_refs[0]; + if (!primaryEntity || primaryEntity.entity_type !== "item") { + return null; + } + return buildFocusObject("item", primaryEntity.value, resultSet.result_set_id, createdAt); +} +function resolveFocusObjectForNavigation(state, intent, resultSet, debugFocusObject, primaryEntityFocusObject) { + if (intent !== "inventory_margin_ranking_for_nomenclature") { + return debugFocusObject ?? primaryEntityFocusObject; + } + if (primaryEntityFocusObject) { + return primaryEntityFocusObject; + } + const previousFocus = state.session_context.active_focus_object; + if (previousFocus?.object_type === "item" && + (!debugFocusObject || debugFocusObject.object_type === "organization")) { + return cloneFocusObject(previousFocus); + } + return debugFocusObject; +} function capResultSets(resultSets) { if (resultSets.length <= MAX_RESULT_SETS) { return resultSets; @@ -573,7 +600,9 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) { created_at: createdAt }; const previousResultSetId = state.session_context.active_result_set_id; - const focusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt); + const debugFocusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt); + const primaryEntityFocusObject = buildFocusObjectFromPrimaryEntityRef(resultSet, createdAt); + const focusObject = resolveFocusObjectForNavigation(state, intent, resultSet, debugFocusObject, primaryEntityFocusObject); const comparisonCounterparty = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true ? readNavigationDiscoveryCounterparty(debug) : null; diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index 157c291..9feeb6d 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -1149,9 +1149,9 @@ function extractInventoryCounterpartyCandidates(row, excludedTokens = []) { excludedComparableTokens.includes(comparable)) { continue; } - candidates.push(normalized); + candidates.push(collapseRepeatedInventoryPartyLabel(normalized)); } - const explicitCounterparty = normalizeCounterpartyDisplayLabel(row.counterparty); + const explicitCounterparty = collapseRepeatedInventoryPartyLabel(normalizeCounterpartyDisplayLabel(row.counterparty)); const explicitComparable = normalizeEntityToken(explicitCounterparty); if (explicitCounterparty && explicitComparable && @@ -1163,6 +1163,50 @@ function extractInventoryCounterpartyCandidates(row, excludedTokens = []) { } return uniqueStrings(candidates); } +function collapseRepeatedInventoryPartyLabel(value) { + const compact = String(value ?? "").replace(/\s+/gu, " ").trim(); + if (!compact) { + return compact; + } + const tokens = compact.split(" ").filter(Boolean); + for (let size = 1; size <= Math.floor(tokens.length / 2); size += 1) { + const collapsedTokens = []; + let changed = false; + for (let index = 0; index < tokens.length;) { + const leftTokens = tokens.slice(index, index + size); + const rightTokens = tokens.slice(index + size, index + size * 2); + if (leftTokens.length === size && + rightTokens.length === size && + normalizeEntityToken(leftTokens.join(" ")) === normalizeEntityToken(rightTokens.join(" "))) { + collapsedTokens.push(...leftTokens); + index += size * 2; + changed = true; + } + else { + collapsedTokens.push(tokens[index]); + index += 1; + } + } + if (changed) { + return collapseRepeatedInventoryPartyLabel(collapsedTokens.join(" ")); + } + } + return compact; +} +function uniqueCollapsedInventoryPartyLabels(values) { + const result = []; + const seen = new Set(); + for (const value of values) { + const collapsed = collapseRepeatedInventoryPartyLabel(value); + const comparable = normalizeEntityToken(collapsed); + if (!collapsed || !comparable || seen.has(comparable)) { + continue; + } + seen.add(comparable); + result.push(collapsed); + } + return result; +} function summarizeInventoryTraceRows(rows, excludedCounterpartyTokens = []) { const items = uniqueStrings(rows .map((row) => extractInventoryItemName(row)) @@ -1173,7 +1217,7 @@ function summarizeInventoryTraceRows(rows, excludedCounterpartyTokens = []) { const organizations = uniqueStrings(rows .map((row) => extractInventoryOrganizationName(row)) .filter((item) => Boolean(item))); - const counterparties = uniqueStrings(rows.flatMap((row) => extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens))); + const counterparties = uniqueCollapsedInventoryPartyLabels(rows.flatMap((row) => extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens))); const documents = uniqueStrings(rows .map((row) => String(row.registrator ?? "").trim()) .filter((item) => item.length > 0 && item !== "(без названия)")); diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 22341e0..4764a9b 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -613,7 +613,7 @@ function hasInventorySupplierFollowupCue(text) { } function hasInventoryPurchaseDocumentsFollowupCue(text) { const value = String(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) || + 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+(?:этой\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) { diff --git a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js index 1ac879b..ea5c633 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js @@ -67,6 +67,61 @@ function normalizeInventoryReplyEntityToken(value) { .trim(); return normalized || null; } +function normalizeInventoryReplyLabelComparable(value) { + const normalized = String(value ?? "") + .trim() + .toLowerCase() + .replace(/ё/gu, "е") + .replace(/С‘/gu, "Рµ") + .replace(/[^\p{L}\p{N}]+/gu, " ") + .replace(/\s+/gu, " ") + .trim(); + return normalized || null; +} +function collapseRepeatedInventoryReplyLabel(value) { + const compact = String(value ?? "").replace(/\s+/gu, " ").trim(); + if (!compact) { + return compact; + } + const tokens = compact.split(/\s+/u).filter(Boolean); + for (let size = 1; size <= Math.floor(tokens.length / 2); size += 1) { + const collapsedTokens = []; + let changed = false; + for (let index = 0; index < tokens.length;) { + const leftTokens = tokens.slice(index, index + size); + const rightTokens = tokens.slice(index + size, index + size * 2); + if (leftTokens.length === size && + rightTokens.length === size && + normalizeInventoryReplyLabelComparable(leftTokens.join(" ")) === normalizeInventoryReplyLabelComparable(rightTokens.join(" "))) { + collapsedTokens.push(...leftTokens); + index += size * 2; + changed = true; + } + else { + collapsedTokens.push(tokens[index]); + index += 1; + } + } + if (changed) { + return collapseRepeatedInventoryReplyLabel(collapsedTokens.join(" ")); + } + } + return compact; +} +function normalizeInventoryTraceSummaryCounterparties(summary) { + const normalized = []; + const seen = new Set(); + for (const counterparty of summary.counterparties) { + const collapsed = collapseRepeatedInventoryReplyLabel(counterparty); + const comparable = normalizeInventoryReplyLabelComparable(collapsed); + if (!collapsed || !comparable || seen.has(comparable)) { + continue; + } + seen.add(comparable); + normalized.push(collapsed); + } + summary.counterparties = normalized; +} function sumInventoryRowAmount(rows) { return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0); } @@ -112,6 +167,9 @@ function asksInventoryMarginAccount41Not01(userMessage) { const text = String(userMessage ?? "").toLowerCase(); return /(?:\b41\b|41\s*сч|сч[её]т[ау]?\s*41)/iu.test(text) && /(?:\b01\b|не\s+ос|основн)/iu.test(text); } +function asksForInventoryConfirmationDocument(userMessage) { + return /(?:каким\s+документом|чем\s+подтвержд|документ(?:ом|ами)?\s+подтвержд|покажи\s+закупочн(?:ый|ые)\s+документ|покажи\s+документ)/iu.test(String(userMessage ?? "")); +} function inventoryRowItemLabel(row, deps) { return deps.summarizeInventoryTraceRows([row]).item; } @@ -210,10 +268,15 @@ function composeInventoryReply(intent, rows, options, deps) { const asOfDate = deps.resolvePayablesAsOfDate(options); const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); const summary = deps.summarizeInventoryTraceRows(purchaseRows); + normalizeInventoryTraceSummaryCounterparties(summary); const itemLabel = summary.item ?? "товар не определен"; + const firstDocument = summary.documents[0] ?? null; + const confirmationDocumentFocus = asksForInventoryConfirmationDocument(options.userMessage); const directAnswerLine = purchaseRows.length <= 0 ? `По позиции ${itemLabel} подтвержденные документы закупки в доступных данных не найдены.` - : `По позиции ${itemLabel} найдено ${deps.formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${deps.formatDateRu(asOfDate)}.`; + : confirmationDocumentFocus && firstDocument + ? `Документ подтверждения по позиции ${itemLabel}: ${firstDocument}.` + : `По позиции ${itemLabel} найдено ${deps.formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${deps.formatDateRu(asOfDate)}.`; const lines = [directAnswerLine]; (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", [ `Дата верхней границы: ${deps.formatDateRu(asOfDate)}.`, @@ -240,6 +303,7 @@ function composeInventoryReply(intent, rows, options, deps) { const asOfDate = deps.resolvePayablesAsOfDate(options); const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); const summary = deps.summarizeInventoryTraceRows(purchaseRows); + normalizeInventoryTraceSummaryCounterparties(summary); const itemLabel = summary.item ?? "товар не определен"; const boundedAsOfLabel = asOfDate ? deps.formatDateRu(asOfDate) : null; const purchaseDateActionFocus = deps.hasInventoryPurchaseDateActionFocus(options.userMessage); @@ -330,6 +394,7 @@ function composeInventoryReply(intent, rows, options, deps) { const asOfDate = deps.resolvePayablesAsOfDate(options); const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); const summary = deps.summarizeInventoryTraceRows(purchaseRows); + normalizeInventoryTraceSummaryCounterparties(summary); const unresolvedRows = purchaseRows.filter((row) => deps.extractInventoryCounterpartyCandidates(row).length === 0); const unresolvedSupplierQuestion = /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(String(options.userMessage ?? "")); if (unresolvedSupplierQuestion) { @@ -406,6 +471,7 @@ function composeInventoryReply(intent, rows, options, deps) { return Boolean(itemToken && agingItemTokens.has(itemToken)); }); const summary = deps.summarizeInventoryTraceRows(purchaseRows); + normalizeInventoryTraceSummaryCounterparties(summary); const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod; const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null; const organizationLabel = agingItems.find((item) => item.organization)?.organization ?? null; @@ -454,6 +520,7 @@ function composeInventoryReply(intent, rows, options, deps) { const requestedItemHint = String(options.itemHint ?? "").trim(); const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : []; const summary = deps.summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens); + normalizeInventoryTraceSummaryCounterparties(summary); const itemLabel = requestedItemHint || (summary.item ?? "товар не определен"); const excludedCounterpartyTokens = [itemLabel]; const directAnswerLine = summary.counterparties.length === 1 @@ -529,7 +596,7 @@ function composeInventoryReply(intent, rows, options, deps) { "оплаты могут помочь сверить поступление денег, но сами по себе не подтверждают валовую прибыль по товарам;", "строгий бухгалтерский расчет требует проводок реализации и себестоимости, а не только банковских движений." ]); - return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("medium", false)); + return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("medium", true)); } if (confirmedEntries.length === 0) { const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage); diff --git a/llm_normalizer/backend/src/services/addressNavigationState.ts b/llm_normalizer/backend/src/services/addressNavigationState.ts index 2db05a6..cd24552 100644 --- a/llm_normalizer/backend/src/services/addressNavigationState.ts +++ b/llm_normalizer/backend/src/services/addressNavigationState.ts @@ -45,6 +45,7 @@ const DISPLAY_ENTITY_TYPE_BY_INTENT: Partial item.trim()).filter(Boolean); const valueCandidate = pieces.length > 0 ? pieces[0] : afterNumber; - const cleaned = valueCandidate.replace(/^["'«»“”„`’‘]+|["'«»“”„`’‘]+$/gu, "").trim(); + const businessLabel = valueCandidate.split(/\s+[—–-]\s+(?=(?:выручка|сумма|количество|стоимость|маржа|себестоим|дата|склад))/iu)[0] ?? valueCandidate; + const cleaned = businessLabel.replace(/^["'«»“”„`’‘]+|["'«»“”„`’‘]+$/gu, "").trim(); if (!cleaned || cleaned.length < 2) { return null; } @@ -455,6 +458,43 @@ function buildFocusObjectFromDebug(debug: Record, resultSetId: return rawValue ? buildFocusObject(canonicalType, rawValue, resultSetId, createdAt) : null; } +function buildFocusObjectFromPrimaryEntityRef( + resultSet: AddressResultSet, + createdAt: string +): AddressFocusObject | null { + if (resultSet.intent !== "inventory_margin_ranking_for_nomenclature") { + return null; + } + const primaryEntity = resultSet.entity_refs[0]; + if (!primaryEntity || primaryEntity.entity_type !== "item") { + return null; + } + return buildFocusObject("item", primaryEntity.value, resultSet.result_set_id, createdAt); +} + +function resolveFocusObjectForNavigation( + state: AddressNavigationState, + intent: AddressIntent, + resultSet: AddressResultSet, + debugFocusObject: AddressFocusObject | null, + primaryEntityFocusObject: AddressFocusObject | null +): AddressFocusObject | null { + if (intent !== "inventory_margin_ranking_for_nomenclature") { + return debugFocusObject ?? primaryEntityFocusObject; + } + if (primaryEntityFocusObject) { + return primaryEntityFocusObject; + } + const previousFocus = state.session_context.active_focus_object; + if ( + previousFocus?.object_type === "item" && + (!debugFocusObject || debugFocusObject.object_type === "organization") + ) { + return cloneFocusObject(previousFocus); + } + return debugFocusObject; +} + function capResultSets(resultSets: AddressResultSet[]): AddressResultSet[] { if (resultSets.length <= MAX_RESULT_SETS) { return resultSets; @@ -663,7 +703,15 @@ export function evolveAddressNavigationStateWithAssistantItem( created_at: createdAt }; const previousResultSetId = state.session_context.active_result_set_id; - const focusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt); + const debugFocusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt); + const primaryEntityFocusObject = buildFocusObjectFromPrimaryEntityRef(resultSet, createdAt); + const focusObject = resolveFocusObjectForNavigation( + state, + intent, + resultSet, + debugFocusObject, + primaryEntityFocusObject + ); const comparisonCounterparty = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true ? readNavigationDiscoveryCounterparty(debug) diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index dcb4ea3..ff286f7 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -1474,9 +1474,9 @@ function extractInventoryCounterpartyCandidates(row: ComposeStageRow, excludedTo ) { continue; } - candidates.push(normalized); + candidates.push(collapseRepeatedInventoryPartyLabel(normalized)); } - const explicitCounterparty = normalizeCounterpartyDisplayLabel(row.counterparty); + const explicitCounterparty = collapseRepeatedInventoryPartyLabel(normalizeCounterpartyDisplayLabel(row.counterparty)); const explicitComparable = normalizeEntityToken(explicitCounterparty); if ( explicitCounterparty && @@ -1491,6 +1491,53 @@ function extractInventoryCounterpartyCandidates(row: ComposeStageRow, excludedTo return uniqueStrings(candidates); } +function collapseRepeatedInventoryPartyLabel(value: string | null | undefined): string { + const compact = String(value ?? "").replace(/\s+/gu, " ").trim(); + if (!compact) { + return compact; + } + const tokens = compact.split(" ").filter(Boolean); + for (let size = 1; size <= Math.floor(tokens.length / 2); size += 1) { + const collapsedTokens: string[] = []; + let changed = false; + for (let index = 0; index < tokens.length; ) { + const leftTokens = tokens.slice(index, index + size); + const rightTokens = tokens.slice(index + size, index + size * 2); + if ( + leftTokens.length === size && + rightTokens.length === size && + normalizeEntityToken(leftTokens.join(" ")) === normalizeEntityToken(rightTokens.join(" ")) + ) { + collapsedTokens.push(...leftTokens); + index += size * 2; + changed = true; + } else { + collapsedTokens.push(tokens[index]); + index += 1; + } + } + if (changed) { + return collapseRepeatedInventoryPartyLabel(collapsedTokens.join(" ")); + } + } + return compact; +} + +function uniqueCollapsedInventoryPartyLabels(values: string[]): string[] { + const result: string[] = []; + const seen = new Set(); + for (const value of values) { + const collapsed = collapseRepeatedInventoryPartyLabel(value); + const comparable = normalizeEntityToken(collapsed); + if (!collapsed || !comparable || seen.has(comparable)) { + continue; + } + seen.add(comparable); + result.push(collapsed); + } + return result; +} + interface InventoryTraceSummary { item: string | null; warehouses: string[]; @@ -1518,7 +1565,7 @@ function summarizeInventoryTraceRows(rows: ComposeStageRow[], excludedCounterpar .map((row) => extractInventoryOrganizationName(row)) .filter((item): item is string => Boolean(item)) ); - const counterparties = uniqueStrings( + const counterparties = uniqueCollapsedInventoryPartyLabels( rows.flatMap((row) => extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens)) ); const documents = uniqueStrings( diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 2fbfd8f..ae04401 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -764,7 +764,7 @@ export function hasInventorySupplierFollowupCue(text: string): boolean { export function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean { const value = String(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( + /(?:по\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+(?:этой\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( diff --git a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts index 30fd23c..6d9578d 100644 --- a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts @@ -145,6 +145,65 @@ function normalizeInventoryReplyEntityToken(value: string | null | undefined): s return normalized || null; } +function normalizeInventoryReplyLabelComparable(value: string | null | undefined): string | null { + const normalized = String(value ?? "") + .trim() + .toLowerCase() + .replace(/ё/gu, "е") + .replace(/С‘/gu, "Рµ") + .replace(/[^\p{L}\p{N}]+/gu, " ") + .replace(/\s+/gu, " ") + .trim(); + return normalized || null; +} + +function collapseRepeatedInventoryReplyLabel(value: string | null | undefined): string { + const compact = String(value ?? "").replace(/\s+/gu, " ").trim(); + if (!compact) { + return compact; + } + const tokens = compact.split(/\s+/u).filter(Boolean); + for (let size = 1; size <= Math.floor(tokens.length / 2); size += 1) { + const collapsedTokens: string[] = []; + let changed = false; + for (let index = 0; index < tokens.length; ) { + const leftTokens = tokens.slice(index, index + size); + const rightTokens = tokens.slice(index + size, index + size * 2); + if ( + leftTokens.length === size && + rightTokens.length === size && + normalizeInventoryReplyLabelComparable(leftTokens.join(" ")) === normalizeInventoryReplyLabelComparable(rightTokens.join(" ")) + ) { + collapsedTokens.push(...leftTokens); + index += size * 2; + changed = true; + } else { + collapsedTokens.push(tokens[index]); + index += 1; + } + } + if (changed) { + return collapseRepeatedInventoryReplyLabel(collapsedTokens.join(" ")); + } + } + return compact; +} + +function normalizeInventoryTraceSummaryCounterparties(summary: InventoryTraceSummary): void { + const normalized: string[] = []; + const seen = new Set(); + for (const counterparty of summary.counterparties) { + const collapsed = collapseRepeatedInventoryReplyLabel(counterparty); + const comparable = normalizeInventoryReplyLabelComparable(collapsed); + if (!collapsed || !comparable || seen.has(comparable)) { + continue; + } + seen.add(comparable); + normalized.push(collapsed); + } + summary.counterparties = normalized; +} + function sumInventoryRowAmount(rows: ComposeStageRow[]): number { return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0); } @@ -204,6 +263,12 @@ function asksInventoryMarginAccount41Not01(userMessage: string | null | undefine return /(?:\b41\b|41\s*сч|сч[её]т[ау]?\s*41)/iu.test(text) && /(?:\b01\b|не\s+ос|основн)/iu.test(text); } +function asksForInventoryConfirmationDocument(userMessage: string | null | undefined): boolean { + return /(?:каким\s+документом|чем\s+подтвержд|документ(?:ом|ами)?\s+подтвержд|покажи\s+закупочн(?:ый|ые)\s+документ|покажи\s+документ)/iu.test( + String(userMessage ?? "") + ); +} + interface InventoryMarginRankingEntry { item: string; revenue: number; @@ -346,10 +411,15 @@ export function composeInventoryReply( const asOfDate = deps.resolvePayablesAsOfDate(options); const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); const summary = deps.summarizeInventoryTraceRows(purchaseRows); + normalizeInventoryTraceSummaryCounterparties(summary); const itemLabel = summary.item ?? "товар не определен"; + const firstDocument = summary.documents[0] ?? null; + const confirmationDocumentFocus = asksForInventoryConfirmationDocument(options.userMessage); const directAnswerLine = purchaseRows.length <= 0 ? `По позиции ${itemLabel} подтвержденные документы закупки в доступных данных не найдены.` + : confirmationDocumentFocus && firstDocument + ? `Документ подтверждения по позиции ${itemLabel}: ${firstDocument}.` : `По позиции ${itemLabel} найдено ${deps.formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${deps.formatDateRu(asOfDate)}.`; const lines: string[] = [directAnswerLine]; appendInventoryBulletSection(lines, "Сводка:", [ @@ -376,6 +446,7 @@ export function composeInventoryReply( const asOfDate = deps.resolvePayablesAsOfDate(options); const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); const summary = deps.summarizeInventoryTraceRows(purchaseRows); + normalizeInventoryTraceSummaryCounterparties(summary); const itemLabel = summary.item ?? "товар не определен"; const boundedAsOfLabel = asOfDate ? deps.formatDateRu(asOfDate) : null; const purchaseDateActionFocus = deps.hasInventoryPurchaseDateActionFocus(options.userMessage); @@ -485,6 +556,7 @@ export function composeInventoryReply( const asOfDate = deps.resolvePayablesAsOfDate(options); const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); const summary = deps.summarizeInventoryTraceRows(purchaseRows); + normalizeInventoryTraceSummaryCounterparties(summary); const unresolvedRows = purchaseRows.filter((row) => deps.extractInventoryCounterpartyCandidates(row).length === 0); const unresolvedSupplierQuestion = /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test( @@ -579,6 +651,7 @@ export function composeInventoryReply( return Boolean(itemToken && agingItemTokens.has(itemToken)); }); const summary = deps.summarizeInventoryTraceRows(purchaseRows); + normalizeInventoryTraceSummaryCounterparties(summary); const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod; const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null; const organizationLabel = agingItems.find((item) => item.organization)?.organization ?? null; @@ -631,6 +704,7 @@ export function composeInventoryReply( const requestedItemHint = String(options.itemHint ?? "").trim(); const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : []; const summary = deps.summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens); + normalizeInventoryTraceSummaryCounterparties(summary); const itemLabel = requestedItemHint || (summary.item ?? "товар не определен"); const excludedCounterpartyTokens = [itemLabel]; const directAnswerLine = @@ -715,7 +789,7 @@ export function composeInventoryReply( "оплаты могут помочь сверить поступление денег, но сами по себе не подтверждают валовую прибыль по товарам;", "строгий бухгалтерский расчет требует проводок реализации и себестоимости, а не только банковских движений." ]); - return buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium", false)); + return buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium", true)); } if (confirmedEntries.length === 0) { const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage); diff --git a/llm_normalizer/backend/tests/addressInventoryProfitabilitySelectedObjectRegression.test.ts b/llm_normalizer/backend/tests/addressInventoryProfitabilitySelectedObjectRegression.test.ts index cc8b6f6..6d4bbf3 100644 --- a/llm_normalizer/backend/tests/addressInventoryProfitabilitySelectedObjectRegression.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryProfitabilitySelectedObjectRegression.test.ts @@ -419,4 +419,54 @@ describe("inventory profitability selected-object regressions", () => { expect(reply).not.toContain("\u0430\u043c\u043e\u0440\u0442\u0438\u0437"); expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); }); + + it("keeps payment-bank margin false-source boundary factual in the full runtime", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 2, + matched_rows: 2, + raw_rows: [ + { + Period: "2020-01-10T00:00:00Z", + Registrator: "Purchase document 1", + AccountDt: "41.01", + AccountKt: "60.01", + Amount: 500, + Quantity: 10, + SubcontoDt1: "Item A", + Organization: "OOO Alternative Plus" + }, + { + Period: "2020-02-10T00:00:00Z", + Registrator: "Sales document 1", + AccountDt: "62.01", + AccountKt: "41.01", + Amount: 1500, + Quantity: 10, + SubcontoKt1: "Item A", + Organization: "OOO Alternative Plus" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle( + "Можно быстро понять маржинальность товаров за 2020 год по оплатам и банку?" + ); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("FACTUAL_SUMMARY"); + expect(result?.reply_type).toBe("factual"); + expect(result?.debug.detected_intent).toBe("inventory_margin_ranking_for_nomenclature"); + expect(result?.debug.capability_id).toBe("inventory_inventory_margin_ranking_for_nomenclature"); + expect(result?.debug.address_coverage_evidence_v1?.coverage_status).toBe("full"); + const reply = String(result?.reply_text ?? ""); + expect(reply).toContain("По оплатам и банку"); + expect(reply).toContain("нельзя честно подтвердить"); + expect(reply).toContain("выручка реализации"); + expect(reply).toContain("себестоимостная база"); + expect(reply).not.toContain("Самая маржинальная позиция"); + expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts index ac424dd..4980b7d 100644 --- a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts +++ b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts @@ -612,6 +612,111 @@ describe("inventory selected-object follow-up", () => { expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000003"); }); + it("routes confirmation-document follow-up over selected item and deduplicates supplier labels", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2020-06-22T00:00:00Z", + Registrator: "Поступление товаров и услуг 00000000020 от 22.06.2020 0:00:00", + AccountDt: "41.01", + AccountKt: "60.01", + Amount: 4744, + SubcontoDt1: "Рулонные шторы. Цвет мадагаскар серый", + SubcontoDt3: "Основной склад", + SubcontoKt1: "ИП Тучкова ИП Тучкова", + Counterparty: "ИП Тучкова", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("Каким документом это подтверждено?", { + followupContext: { + previous_intent: "inventory_purchase_provenance_for_item", + target_intent: "inventory_purchase_provenance_for_item", + root_intent: "inventory_margin_ranking_for_nomenclature", + previous_filters: { + item: "Рулонные шторы. Цвет мадагаскар серый", + organization: "ООО \\Альтернатива Плюс\\", + as_of_date: "2020-12-31", + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + previous_anchor_type: "item", + previous_anchor_value: "Рулонные шторы. Цвет мадагаскар серый", + current_frame_kind: "inventory_drilldown" + } + }); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("FACTUAL_LIST"); + expect(result?.debug.detected_intent).toBe("inventory_purchase_documents_for_item"); + expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_documents_for_item_v1"); + expect(result?.debug.extracted_filters?.item).toBe("Рулонные шторы. Цвет мадагаскар серый"); + expect(result?.debug.extracted_filters?.counterparty).toBeUndefined(); + const reply = String(result?.reply_text ?? ""); + expect(reply.split(/\r?\n/u)[0]).toContain("Документ подтверждения"); + expect(reply).toContain("Поступление товаров и услуг 00000000020"); + expect(reply).toContain("Поставщик: ИП Тучкова"); + expect(reply).not.toContain("ИП Тучкова ИП Тучкова"); + expect(reply).not.toContain("уточните контрагента"); + }); + + it("deduplicates supplier labels in selected-item provenance answer", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2020-06-22T00:00:00Z", + Registrator: "Поступление товаров и услуг 00000000020 от 22.06.2020 0:00:00", + AccountDt: "41.01", + AccountKt: "60.01", + Amount: 4744, + SubcontoDt1: "Рулонные шторы. Цвет мадагаскар серый", + SubcontoDt3: "Основной склад", + SubcontoKt1: "ИП Тучкова ИП Тучкова", + Counterparty: "ИП Тучкова", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("А по этой позиции кто поставщик?", { + followupContext: { + previous_intent: "inventory_margin_ranking_for_nomenclature", + target_intent: "inventory_margin_ranking_for_nomenclature", + root_intent: "inventory_margin_ranking_for_nomenclature", + previous_filters: { + item: "Рулонные шторы. Цвет мадагаскар серый", + organization: "ООО \\Альтернатива Плюс\\", + as_of_date: "2020-12-31", + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + previous_anchor_type: "item", + previous_anchor_value: "Рулонные шторы. Цвет мадагаскар серый", + current_frame_kind: "inventory_drilldown" + } + }); + + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item"); + const reply = String(result?.reply_text ?? ""); + expect(reply).toContain("подтвержден поставщик: ИП Тучкова"); + expect(reply).not.toContain("ИП Тучкова ИП Тучкова"); + expect(reply).not.toContain("однозначный поставщик не подтвержден"); + expect(reply).not.toContain("несколько поставщиков"); + }); + it("routes buyer follow-up over the same selected item into sale trace instead of replaying provenance", async () => { executeAddressMcpQueryMock.mockResolvedValueOnce({ fetched_rows: 1, diff --git a/llm_normalizer/backend/tests/addressNavigationState.test.ts b/llm_normalizer/backend/tests/addressNavigationState.test.ts index d14586a..a753562 100644 --- a/llm_normalizer/backend/tests/addressNavigationState.test.ts +++ b/llm_normalizer/backend/tests/addressNavigationState.test.ts @@ -452,6 +452,151 @@ describe("address navigation state", () => { expect(evolved.session_context.active_focus_object?.label).toBe("Диван трехместный"); expect(evolved.session_context.active_focus_object?.provenance_result_set_id).toBe("rs-msg-a3"); }); + + it("captures top margin-ranking item as active focus for selected-object follow-ups", () => { + const base = createEmptyAddressNavigationState("asst-margin", "2026-04-12T10:00:00.000Z"); + const assistantItem = { + message_id: "msg-margin", + session_id: "asst-margin", + role: "assistant", + text: [ + "Самая маржинальная позиция за период 01.01.2020 - 31.12.2020: Рулонные шторы. Цвет мадагаскар серый — маржа 96,00%.", + "Высокая валовая маржинальность (топ по проценту маржи):", + "1. Рулонные шторы. Цвет мадагаскар серый — выручка 118.552,00 ₽, себестоимостная база 4.744,00 ₽, маржа 96,00%.", + "2. Портьерные шторы Garden kolor — выручка 458.472,00 ₽, себестоимостная база 41.480,00 ₽, маржа 90,95%." + ].join("\n"), + reply_type: "factual", + created_at: "2026-04-12T10:03:00.000Z", + trace_id: "address-margin", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_margin_ranking_for_nomenclature", + selected_recipe: "address_inventory_margin_ranking_for_nomenclature_v1", + extracted_filters: { + period_from: "2020-01-01", + period_to: "2020-12-31", + organization: "ООО Альтернатива Плюс" + }, + anchor_type: "unknown", + anchor_value_resolved: null, + anchor_value_raw: null, + dialog_continuation_contract_v2: { + decision: "new_topic" + } + } + } as any; + + const evolved = evolveAddressNavigationStateWithAssistantItem(base, assistantItem, 3); + expect(evolved.result_sets[0]?.type).toBe("inventory_trace"); + expect(evolved.result_sets[0]?.entity_refs[0]?.entity_type).toBe("item"); + expect(evolved.result_sets[0]?.entity_refs[0]?.value).toBe("Рулонные шторы. Цвет мадагаскар серый"); + expect(evolved.session_context.active_focus_object?.object_type).toBe("item"); + expect(evolved.session_context.active_focus_object?.label).toBe("Рулонные шторы. Цвет мадагаскар серый"); + }); + + it("keeps margin-ranking item focus when an explanatory follow-up resolves only organization anchor", () => { + const base = createEmptyAddressNavigationState("asst-margin-followup", "2026-04-12T10:00:00.000Z"); + const rootItem = { + message_id: "msg-margin-root", + session_id: "asst-margin-followup", + role: "assistant", + text: [ + "Самая маржинальная позиция за период 01.01.2020 - 31.12.2020: Рулонные шторы. Цвет мадагаскар серый — маржа 96,00%.", + "Высокая валовая маржинальность:", + "1. Рулонные шторы. Цвет мадагаскар серый — выручка 118.552,00 ₽, себестоимостная база 4.744,00 ₽, маржа 96,00%." + ].join("\n"), + reply_type: "factual", + created_at: "2026-04-12T10:03:00.000Z", + trace_id: "address-margin-root", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_margin_ranking_for_nomenclature", + selected_recipe: "address_inventory_margin_ranking_for_nomenclature_v1", + extracted_filters: { + period_from: "2020-01-01", + period_to: "2020-12-31", + organization: "ООО Альтернатива Плюс" + }, + anchor_type: "unknown", + dialog_continuation_contract_v2: { + decision: "new_topic" + } + } + } as any; + const afterRoot = evolveAddressNavigationStateWithAssistantItem(base, rootItem, 1); + const explanationItem = { + message_id: "msg-margin-explain", + session_id: "asst-margin-followup", + role: "assistant", + text: "Маржу посчитал по реализации и себестоимостной базе организации ООО Альтернатива Плюс.", + reply_type: "factual", + created_at: "2026-04-12T10:04:00.000Z", + trace_id: "address-margin-explain", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_margin_ranking_for_nomenclature", + selected_recipe: "address_inventory_margin_ranking_for_nomenclature_v1", + extracted_filters: { + period_from: "2020-01-01", + period_to: "2020-12-31", + organization: "ООО Альтернатива Плюс" + }, + anchor_type: "organization", + anchor_value_resolved: "ООО Альтернатива Плюс", + dialog_continuation_contract_v2: { + decision: "continue_previous", + previous_intent: "inventory_margin_ranking_for_nomenclature", + target_intent: "inventory_margin_ranking_for_nomenclature" + } + } + } as any; + + const evolved = evolveAddressNavigationStateWithAssistantItem(afterRoot, explanationItem, 2); + + expect(evolved.session_context.active_focus_object?.object_type).toBe("item"); + expect(evolved.session_context.active_focus_object?.label).toBe("Рулонные шторы. Цвет мадагаскар серый"); + expect(evolved.navigation_history[1]?.target_object_id).toBe( + "item:рулонные шторы. цвет мадагаскар серый" + ); + }); + + it("prefers top margin-ranking item over organization anchor on new margin answers", () => { + const base = createEmptyAddressNavigationState("asst-margin-org-anchor", "2026-04-12T10:00:00.000Z"); + const assistantItem = { + message_id: "msg-margin-org-anchor", + session_id: "asst-margin-org-anchor", + role: "assistant", + text: [ + "Самая маржинальная позиция за период 01.01.2020 - 31.12.2020: Рулонные шторы. Цвет мадагаскар серый — маржа 96,00%.", + "Высокая валовая маржинальность:", + "1. Рулонные шторы. Цвет мадагаскар серый — выручка 118.552,00 ₽, себестоимостная база 4.744,00 ₽, маржа 96,00%." + ].join("\n"), + reply_type: "factual", + created_at: "2026-04-12T10:03:00.000Z", + trace_id: "address-margin-org-anchor", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_margin_ranking_for_nomenclature", + selected_recipe: "address_inventory_margin_ranking_for_nomenclature_v1", + extracted_filters: { + period_from: "2020-01-01", + period_to: "2020-12-31", + organization: "ООО Альтернатива Плюс" + }, + anchor_type: "organization", + anchor_value_resolved: "ООО Альтернатива Плюс", + dialog_continuation_contract_v2: { + decision: "new_topic" + } + } + } as any; + + const evolved = evolveAddressNavigationStateWithAssistantItem(base, assistantItem, 1); + + expect(evolved.session_context.active_focus_object?.object_type).toBe("item"); + expect(evolved.session_context.active_focus_object?.label).toBe("Рулонные шторы. Цвет мадагаскар серый"); + }); + it("derives single organization scope from inventory answer text when filters omit organization", () => { const base = createEmptyAddressNavigationState("asst-5", "2026-04-12T10:00:00.000Z"); const assistantItem = {