Закрепить margin follow-up по выбранной позиции

This commit is contained in:
dctouch 2026-06-02 00:23:36 +03:00
parent 88c92e316a
commit 908d011743
12 changed files with 731 additions and 15 deletions

View File

@ -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|амортизац|объект ОС)"
]
}
]
}

View File

@ -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;

View File

@ -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 !== "(без названия)"));

View File

@ -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) {

View File

@ -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);

View File

@ -45,6 +45,7 @@ const DISPLAY_ENTITY_TYPE_BY_INTENT: Partial<Record<AddressIntent, AddressFocusO
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"
@ -72,6 +73,7 @@ const RESULT_SET_TYPE_BY_INTENT: Partial<Record<AddressIntent, AddressResultSetT
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",
@ -196,7 +198,8 @@ function parseEntityCandidateFromLine(line: string): { index: number; value: str
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;
}
@ -455,6 +458,43 @@ function buildFocusObjectFromDebug(debug: Record<string, unknown>, 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)

View File

@ -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<string>();
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(

View File

@ -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(

View File

@ -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<string>();
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);

View File

@ -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);
});
});

View File

@ -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,

View File

@ -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 = {