ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Склад: усилить follow-up оркестрацию и business-first формат ответов

This commit is contained in:
2026-04-14 21:42:19 +03:00
parent 97b2a9b028
commit c020ef08e1
25 changed files with 1742 additions and 336 deletions
@@ -1359,7 +1359,7 @@ function hasInventoryProvenanceSignalV2(text) {
}
function hasInventoryPurchaseDateSignal(text) {
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
const hasPurchaseDateCue = /(?:когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(text);
const hasPurchaseDateCue = /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(text);
return hasItemCue && hasPurchaseDateCue;
}
function hasInventoryPurchaseDocumentsSignalV2(text) {
@@ -1369,7 +1369,7 @@ function hasInventoryPurchaseDocumentsSignalV2(text) {
}
function hasInventorySaleTraceSignalV2(text) {
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
const hasTraceCue = /(?:кому\s+был\s+продан|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test(text);
const hasTraceCue = /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test(text);
return hasItemCue && hasTraceCue;
}
function hasInventorySupplierStockOverlapSignal(text) {
@@ -189,7 +189,10 @@ function resolveNavigationAction(debug, hasFocusObject) {
return hasFocusObject ? "drilldown" : "open";
}
function buildFocusObjectFromDebug(debug, resultSetId, createdAt) {
const rawValue = toNonEmptyString(debug.anchor_value_resolved) ?? toNonEmptyString(debug.anchor_value_raw);
const extractedFilters = toObject(debug.extracted_filters) ?? {};
const rawValue = toNonEmptyString(debug.anchor_value_resolved) ??
toNonEmptyString(debug.anchor_value_raw) ??
toNonEmptyString(extractedFilters.item);
if (!rawValue) {
return null;
}
@@ -238,6 +238,16 @@ function normalizeQuestionText(value) {
.replace(/\s+/g, " ")
.trim();
}
function hasInventoryPurchaseDateActionFocus(userMessage) {
const text = normalizeQuestionText(userMessage);
if (!text) {
return false;
}
if (/^(?:когда|когда\?|дата\s+закупки|purchase\s+date)\??$/iu.test(text)) {
return true;
}
return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?(?:купили|был\s+куплен|куплен|это\s+купили|эту\s+позицию\s+купили|ее\s+купили)|дата\s+закупки|purchase\s+date)/iu.test(text);
}
function normalizeIsoDateOnly(value) {
const parsed = parseIsoDateToken(value);
if (!parsed) {
@@ -3059,28 +3069,12 @@ function composeFactualReply(intent, rows, options = {}) {
const uniqueWarehouses = uniqueStrings(positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0));
const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0);
const lines = [
`Собран подтвержденный срез товаров на складах на ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Результат: подтвержденный список товарных остатков на дату.",
"",
"Блок 2. Что учтено",
`- Дата среза: ${formatDateRu(asOfDate)}.`,
"- Контур: остатки по счету 41.01 «Товары на складах».",
"- Базовая единица детализации: одна строка = товар, склад и организация на дату.",
"",
"Блок 3. Сводка",
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
`- Позиции с ненулевым остатком: ${formatNumberWithDots(positions.length)}.`,
`- Уникальных товаров: ${formatNumberWithDots(uniqueItems.length)}.`,
`- Уникальных складов: ${formatNumberWithDots(uniqueWarehouses.length)}.`,
`- Суммарное количество: ${formatNumberWithDots(totalQuantity, 3)}.`,
`- Суммарная стоимость: ${formatMoneyRub(totalAmount)}.`,
"",
"Блок 4. Подтвержденные позиции"
];
const directAnswerLine = positions.length > 0
? `На ${formatDateRu(asOfDate)} на складе подтверждено ${formatNumberWithDots(positions.length)} позиций с остатком на ${formatMoneyRub(totalAmount)}.`
: `На ${formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`;
const lines = [directAnswerLine];
if (positions.length > 0) {
lines.push("", "Позиции:");
lines.push(...positions.slice(0, 20).map((item, index) => {
const warehouseLabel = item.warehouse ?? "склад не определен";
const organizationLabel = item.organization ? ` | организация: ${item.organization}` : "";
@@ -3090,7 +3084,11 @@ function composeFactualReply(intent, rows, options = {}) {
}));
}
else {
lines.push("- На дату среза товары с ненулевым остатком по счету 41.01 не найдены.");
lines.push("", "Позиции:", "- На дату среза товары с ненулевым остатком по счету 41.01 не найдены.");
}
lines.push("", "Подтверждение:", `- Дата среза: ${formatDateRu(asOfDate)}.`, "- Контур: остатки по счету 41.01 «Товары на складах».", `- Уникальных товаров: ${formatNumberWithDots(uniqueItems.length)}.`, `- Уникальных складов: ${formatNumberWithDots(uniqueWarehouses.length)}.`, `- Суммарное количество: ${formatNumberWithDots(totalQuantity, 3)}.`);
if (rows.length !== positions.length) {
lines.push(`- Строк в подтвержденной выборке: ${formatNumberWithDots(rows.length)}.`);
}
return {
responseType: positions.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
@@ -3107,28 +3105,20 @@ function composeFactualReply(intent, rows, options = {}) {
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен";
const directAnswerLine = summary.counterparties.length === 1
? `По товару ${itemLabel} документы поступления связаны с поставщиком: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? `По товару ${itemLabel} документы поступления ведут к нескольким поставщикам: ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По товару ${itemLabel} найдены документы поступления, но поставщик не материализован отдельным полем в текущем exact-контуре.`;
const lines = [
directAnswerLine,
`Собран подтвержденный список документов поступления по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Результат: подтвержденные движения поступления товара на 41.01 по доступным бухгалтерским проводкам.",
"",
"Блок 2. Что учтено",
`- Дата верхней границы: ${formatDateRu(asOfDate)}.`,
"- Контур: движения, где товар поступает на счет 41.01.",
`- Документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`,
`- Операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.`
];
if (summary.counterparties.length > 0) {
lines.push(`- Найденные контрагенты в закупочных движениях: ${summary.counterparties.slice(0, 3).join("; ")}.`);
const directAnswerLine = purchaseRows.length <= 0
? `По позиции ${itemLabel} подтвержденные документы закупки в доступном контуре не найдены.`
: `По позиции ${itemLabel} найдено ${formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${formatDateRu(asOfDate)}.`;
const lines = [directAnswerLine];
lines.push("", "Подтверждение:");
lines.push(`- Дата верхней границы: ${formatDateRu(asOfDate)}.`);
lines.push(`- Операций поступления в выборке: ${formatNumberWithDots(purchaseRows.length)}.`);
if (summary.counterparties.length === 1) {
lines.push(`- Поставщик: ${summary.counterparties[0]}.`);
}
lines.push("", "Блок 3. Документы");
else if (summary.counterparties.length > 1) {
lines.push(`- В закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`);
}
lines.push("", "Документы:");
if (purchaseRows.length > 0) {
lines.push(...formatInventoryTraceRows(purchaseRows, 12));
}
@@ -3150,25 +3140,55 @@ function composeFactualReply(intent, rows, options = {}) {
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен";
const purchaseDateActionFocus = hasInventoryPurchaseDateActionFocus(options.userMessage);
if (purchaseDateActionFocus) {
const firstPurchaseDate = inventoryTraceDateLabel(summary.firstPeriod);
const lastPurchaseDate = inventoryTraceDateLabel(summary.lastPeriod);
const directAnswerLine = purchaseRows.length <= 0 || !summary.firstPeriod
? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.`
: summary.firstPeriod === summary.lastPeriod
? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.`
: `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`;
const lines = [directAnswerLine];
if (purchaseRows.length > 0) {
lines.push("", "Подтверждение:");
lines.push(`- Первая подтвержденная дата закупки: ${firstPurchaseDate}.`);
if (summary.firstPeriod !== summary.lastPeriod) {
lines.push(`- Последняя подтвержденная дата закупки: ${lastPurchaseDate}.`);
}
if (summary.counterparties.length === 1) {
lines.push(`- Поставщик в доступном закупочном следе: ${summary.counterparties[0]}.`);
}
else if (summary.counterparties.length > 1) {
lines.push(`- В доступном закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`);
}
if (summary.documents.length > 0) {
lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`);
}
if (summary.firstPeriod && asOfDate && summary.firstPeriod < asOfDate) {
lines.push(`- Дата вопроса по остатку: ${formatDateRu(asOfDate)}; дата закупки показана по подтвержденному закупочному следу.`);
}
}
return {
responseType: "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: purchaseRows.length > 0 ? "strong" : "medium",
balance_confirmed: purchaseRows.length > 0
}
};
}
const directAnswerLine = summary.counterparties.length === 1
? `Товар ${itemLabel} по доступным закупочным движениям связан с поставщиком: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? `По доступным закупочным движениям по товару ${itemLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По товару ${itemLabel} найден закупочный след, но поставщик не материализован отдельным полем в текущем exact-контуре.`;
const lines = [
directAnswerLine,
`Собран подтвержденный закупочный след по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Результат: показаны подтвержденные закупочные движения на 41.01 по выбранному товару.",
"- Важно: без партионности этот контур не подменяет собой лот-level доказательство происхождения текущего остатка.",
"",
"Блок 2. Сводка",
`- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
`- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`,
`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`
];
const lines = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`);
lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`);
lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`);
lines.push(`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`);
if (summary.counterparties.length === 1) {
lines.push(`- По доступным закупочным движениям товар связан с поставщиком: ${summary.counterparties[0]}.`);
}
@@ -3179,7 +3199,10 @@ function composeFactualReply(intent, rows, options = {}) {
lines.push("- Закупочные документы найдены, но поставщик не материализован отдельным полем в текущем exact-контуре.");
}
if (summary.documents.length > 0) {
lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 8));
lines.push("", "Опорные документы:", ...formatInventoryTraceRows(purchaseRows, 8));
}
if (purchaseRows.length > 0) {
lines.push("", "Сервисно:", "- Без партионности этот контур показывает документально наблюдаемый закупочный след, а не лот-level происхождение текущего остатка.");
}
return {
responseType: purchaseRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY",
@@ -3304,19 +3327,11 @@ function composeFactualReply(intent, rows, options = {}) {
: summary.counterparties.length > 1
? `По товару ${itemLabel} найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По товару ${itemLabel} покупатель в текущем exact-контуре не материализован.`;
const lines = [
directAnswerLine,
`Собран подтвержденный след выбытия по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Результат: показаны подтвержденные движения выбытия товара со счета 41.01.",
"",
"Блок 2. Сводка",
`- Первая найденная дата выбытия: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
`- Последняя найденная дата выбытия: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Документов выбытия: ${formatNumberWithDots(summary.documents.length)}.`,
`- Операций выбытия: ${formatNumberWithDots(saleRows.length)}.`
];
const lines = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Первая найденная дата выбытия: ${inventoryTraceDateLabel(summary.firstPeriod)}.`);
lines.push(`- Последняя найденная дата выбытия: ${inventoryTraceDateLabel(summary.lastPeriod)}.`);
lines.push(`- Документов выбытия: ${formatNumberWithDots(summary.documents.length)}.`);
lines.push(`- Операций выбытия: ${formatNumberWithDots(saleRows.length)}.`);
if (summary.counterparties.length === 1) {
lines.push(`- По доступным движениям товар отгружался покупателю: ${summary.counterparties[0]}.`);
}
@@ -3326,7 +3341,7 @@ function composeFactualReply(intent, rows, options = {}) {
else if (saleRows.length > 0) {
lines.push("- Документы выбытия найдены, но покупатель не материализован отдельным полем в текущем exact-контуре.");
}
lines.push("", "Блок 3. Документы выбытия");
lines.push("", "Документы выбытия:");
if (saleRows.length > 0) {
lines.push(...formatInventoryTraceRows(saleRows, 12));
}
@@ -3353,14 +3368,9 @@ function composeFactualReply(intent, rows, options = {}) {
const directAnswerLine = purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1
? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.`
: `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`;
const lines = [
directAnswerLine,
`Собрана документальная цепочка по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
`- Закупочных движений на 41.01: ${formatNumberWithDots(purchaseRows.length)}.`,
`- Движений выбытия со счета 41.01: ${formatNumberWithDots(saleRows.length)}.`
];
const lines = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Закупочных движений на 41.01: ${formatNumberWithDots(purchaseRows.length)}.`);
lines.push(`- Движений выбытия со счета 41.01: ${formatNumberWithDots(saleRows.length)}.`);
if (purchaseRows.length > 0 && saleRows.length > 0) {
lines.push("- В текущем контуре найдены обе стороны цепочки: поступление и последующее выбытие.");
}
@@ -3374,10 +3384,10 @@ function composeFactualReply(intent, rows, options = {}) {
lines.push("- Для выбранного товара не найдено движений по 41.01, из которых можно собрать цепочку.");
}
if (purchaseRows.length > 0) {
lines.push("", "Блок 2. Закупка", `- Первая дата: ${inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`, `- Последняя дата: ${inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`, ...formatInventoryTraceRows(purchaseRows, 6));
lines.push("", "Закупка:", `- Первая дата: ${inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`, `- Последняя дата: ${inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`, ...formatInventoryTraceRows(purchaseRows, 6));
}
if (saleRows.length > 0) {
lines.push("", "Блок 3. Выбытие", `- Первая дата: ${inventoryTraceDateLabel(saleSummary.firstPeriod)}.`, `- Последняя дата: ${inventoryTraceDateLabel(saleSummary.lastPeriod)}.`, ...formatInventoryTraceRows(saleRows, 6));
lines.push("", "Продажа:", `- Первая дата: ${inventoryTraceDateLabel(saleSummary.firstPeriod)}.`, `- Последняя дата: ${inventoryTraceDateLabel(saleSummary.lastPeriod)}.`, ...formatInventoryTraceRows(saleRows, 6));
}
return {
responseType: purchaseRows.length > 0 || saleRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY",
@@ -262,6 +262,22 @@ function hasInventorySupplierFollowupCue(text) {
function hasInventoryPurchaseDocumentsFollowupCue(text) {
return /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(String(text ?? ""));
}
function hasInventoryPurchaseDateFollowupCue(text) {
return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test(String(text ?? ""));
}
function hasBareInventoryPurchaseDateFollowupCue(text) {
const normalized = String(text ?? "").trim().toLowerCase();
if (!normalized) {
return false;
}
return /(?:^|\s)(?:когда|а\s+когда|ну\s+когда)(?=$|[\s,.;:!?])/iu.test(normalized) && normalized.split(/\s+/).filter(Boolean).length <= 3;
}
function hasInventorySaleFollowupCue(text) {
return /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кому\s+дальше\s+продали|кто\s+купил|buyer|покупател)/iu.test(String(text ?? ""));
}
function hasInventoryPurchaseToSaleChainFollowupCue(text) {
return /(?:через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale)/iu.test(String(text ?? ""));
}
function hasAddressFollowupContextSignal(text) {
const normalized = String(text ?? "").trim();
if (!normalized) {
@@ -448,8 +464,10 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
intent === "inventory_aging_by_purchase_date") &&
!toNonEmptyString(merged.item) &&
previousItem) {
merged.item = previousItem;
reasons.push("item_from_followup_context");
if (intent !== "inventory_aging_by_purchase_date") {
merged.item = previousItem;
reasons.push("item_from_followup_context");
}
}
if (sameDateRequested) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
@@ -459,7 +477,11 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
}
}
if (!sameDateRequested &&
(intent === "inventory_sale_trace_for_item" || intent === "inventory_purchase_to_sale_chain") &&
(intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") &&
!hasExplicitPeriodLiteral(userMessage) &&
!hasExplicitCurrentDateHint(userMessage)) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
@@ -471,6 +493,13 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
reasons.push("as_of_date_from_followup_context");
}
}
if (intent === "inventory_aging_by_purchase_date") {
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(String(userMessage ?? ""));
if (toNonEmptyString(merged.item) && !explicitItemMention) {
delete merged.item;
reasons.push("item_cleared_for_stock_slice_aging");
}
}
if (!sameDateRequested &&
hasFollowupSignalForConfirmed &&
!hasExplicitPeriodLiteral(userMessage) &&
@@ -667,6 +696,52 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
};
}
}
if (inventorySelectedObjectFollowup && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) {
if (detectedIntent.intent === "unknown" ||
detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
detectedIntent.intent === previousIntent ||
detectedIntent.intent === "inventory_on_hand_as_of_date") {
return {
intent: "inventory_purchase_provenance_for_item",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"]
};
}
}
if (inventorySelectedObjectFollowup && hasInventorySaleFollowupCue(normalizedMessage)) {
if (detectedIntent.intent === "unknown" ||
detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === previousIntent) {
return {
intent: "inventory_sale_trace_for_item",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"]
};
}
}
if (inventorySelectedObjectFollowup && hasInventoryPurchaseToSaleChainFollowupCue(normalizedMessage)) {
if (detectedIntent.intent === "unknown" ||
detectedIntent.intent === "inventory_sale_trace_for_item" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === previousIntent) {
return {
intent: "inventory_purchase_to_sale_chain",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"]
};
}
}
if (previousIsInventoryFamily &&
hasFollowupSignal &&
hasBareInventoryPurchaseDateFollowupCue(normalizedMessage) &&
(detectedIntent.intent === "unknown" || detectedIntent.intent === previousIntent)) {
return {
intent: "inventory_purchase_provenance_for_item",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"]
};
}
if (hasPreviousContract) {
if (detectedIntent.intent === "list_contracts_by_counterparty") {
if (hasBankSignal(normalizedMessage)) {
+39 -3
View File
@@ -2163,6 +2163,9 @@ function readAddressFilterString(addressDebug, key) {
}
return toNonEmptyString(filters[key]);
}
function readAddressInventoryItemFilter(addressDebug) {
return readAddressFilterString(addressDebug, "item");
}
function isAddressLaneDebugPayload(debug) {
if (!debug || typeof debug !== "object") {
return false;
@@ -2516,6 +2519,7 @@ function buildAddressFollowupOffer(addressDebug) {
const anchorType = toNonEmptyString(addressDebug.anchor_type);
const anchorValue = toNonEmptyString(addressDebug.anchor_value_resolved) ??
toNonEmptyString(addressDebug.anchor_value_raw) ??
readAddressInventoryItemFilter(addressDebug) ??
readAddressFilterString(addressDebug, "counterparty") ??
readAddressFilterString(addressDebug, "contract") ??
readAddressFilterString(addressDebug, "account");
@@ -2689,6 +2693,26 @@ function hasShortDebtMirrorFollowupSignal(userMessage) {
/^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(sample) ||
/^(?:р°|a|рё|i)\s+(?:рјс‹\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(sample));
}
function isInventorySelectedObjectIntent(intent) {
return intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain";
}
function hasShortInventoryObjectFollowupSignal(userMessage) {
const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase());
const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
const samples = [rawText, repairedText].filter((item) => item.length > 0);
if (samples.length === 0) {
return false;
}
const minTokens = samples.reduce((min, sample) => Math.min(min, countTokens(sample)), Number.POSITIVE_INFINITY);
if (minTokens > 8) {
return false;
}
return samples.some((sample) => /^(?:кто|когда|документы|сумма|поставщик|покупатель)(?:\?)?$/iu.test(sample) ||
/^(?:когда\s+(?:примерно\s+)?купили(?:\s+ее)?|каким\s+документом|покажи\s+документы|по\s+каким\s+документам|все\s+закупки|все\s+поступления|кому\s+(?:мы\s+)?продали|кто\s+купил|цепочка|путь\s+товара)(?:\?)?$/iu.test(sample));
}
function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase());
if (!normalized || countTokens(normalized) > 10) {
@@ -2719,14 +2743,18 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
(isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) ||
(toNonEmptyString(alternateMessage) ? isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false));
const sourceIntentHint = toNonEmptyString(previousAddressDebug?.detected_intent);
const inventoryShortFollowupPrimary = isInventorySelectedObjectIntent(sourceIntentHint) && hasShortInventoryObjectFollowupSignal(userMessage);
const inventoryShortFollowupAlternate = isInventorySelectedObjectIntent(sourceIntentHint) && toNonEmptyString(alternateMessage)
? hasShortInventoryObjectFollowupSignal(String(alternateMessage ?? ""))
: false;
const debtRoleSwapPrimary = sourceIntentHint ? resolveDebtRoleSwapFollowupIntent(userMessage, sourceIntentHint) : null;
const debtRoleSwapAlternate = sourceIntentHint && toNonEmptyString(alternateMessage)
? resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint)
: null;
const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null;
const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary);
const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || inventoryShortFollowupPrimary;
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate)
? hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || inventoryShortFollowupAlternate
: false;
const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage)
@@ -2766,6 +2794,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
let previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
let previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
toNonEmptyString(previousAddressDebug.anchor_value_raw) ??
readAddressFilterString(previousAddressDebug, "item") ??
readAddressFilterString(previousAddressDebug, "counterparty") ??
readAddressFilterString(previousAddressDebug, "account") ??
readAddressFilterString(previousAddressDebug, "contract");
@@ -3966,6 +3995,12 @@ function resolveAssistantOrchestrationDecision(input) {
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage);
const protectedInventoryShortFollowup = Boolean(followupContext &&
isInventorySelectedObjectIntent(toNonEmptyString(followupContext.previous_intent)) &&
(hasShortInventoryObjectFollowupSignal(rawUserMessage) ||
hasShortInventoryObjectFollowupSignal(repairedRawUserMessage) ||
hasShortInventoryObjectFollowupSignal(effectiveAddressUserMessage) ||
hasShortInventoryObjectFollowupSignal(repairedEffectiveAddressUserMessage)));
const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal;
const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery &&
!capabilityMetaQuery &&
@@ -3975,7 +4010,8 @@ function resolveAssistantOrchestrationDecision(input) {
intentResolution.intent === "unknown");
const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate &&
deterministicNonDomainGuard &&
(llmFirstUnsupportedCandidate || llmContractMode === null));
(llmFirstUnsupportedCandidate || llmContractMode === null) &&
!protectedInventoryShortFollowup);
const hardMetaMode = dataScopeMetaQuery
? "data_scope"
: capabilityMetaQuery && !dataRetrievalSignal
@@ -1642,7 +1642,9 @@ function hasInventoryProvenanceSignalV2(text: string): boolean {
function hasInventoryPurchaseDateSignal(text: string): boolean {
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
const hasPurchaseDateCue = /(?:когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(text);
const hasPurchaseDateCue = /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(
text
);
return hasItemCue && hasPurchaseDateCue;
}
@@ -1656,9 +1658,10 @@ function hasInventoryPurchaseDocumentsSignalV2(text: string): boolean {
function hasInventorySaleTraceSignalV2(text: string): boolean {
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
const hasTraceCue = /(?:кому\s+был\s+продан|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test(
text
);
const hasTraceCue =
/(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test(
text
);
return hasItemCue && hasTraceCue;
}
@@ -216,7 +216,11 @@ function resolveNavigationAction(debug: Record<string, unknown>, hasFocusObject:
}
function buildFocusObjectFromDebug(debug: Record<string, unknown>, resultSetId: string, createdAt: string): AddressFocusObject | null {
const rawValue = toNonEmptyString(debug.anchor_value_resolved) ?? toNonEmptyString(debug.anchor_value_raw);
const extractedFilters = toObject(debug.extracted_filters) ?? {};
const rawValue =
toNonEmptyString(debug.anchor_value_resolved) ??
toNonEmptyString(debug.anchor_value_raw) ??
toNonEmptyString(extractedFilters.item);
if (!rawValue) {
return null;
}
@@ -369,6 +369,19 @@ function normalizeQuestionText(value: string | null | undefined): string {
.trim();
}
function hasInventoryPurchaseDateActionFocus(userMessage: string | null | undefined): boolean {
const text = normalizeQuestionText(userMessage);
if (!text) {
return false;
}
if (/^(?:когда|когда\?|дата\s+закупки|purchase\s+date)\??$/iu.test(text)) {
return true;
}
return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?(?:купили|был\s+куплен|куплен|это\s+купили|эту\s+позицию\s+купили|ее\s+купили)|дата\s+закупки|purchase\s+date)/iu.test(
text
);
}
function normalizeIsoDateOnly(value: string | null | undefined): string | null {
const parsed = parseIsoDateToken(value);
if (!parsed) {
@@ -3967,30 +3980,14 @@ export function composeFactualReply(
);
const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0);
const lines: string[] = [
`Собран подтвержденный срез товаров на складах на ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Результат: подтвержденный список товарных остатков на дату.",
"",
"Блок 2. Что учтено",
`- Дата среза: ${formatDateRu(asOfDate)}.`,
"- Контур: остатки по счету 41.01 «Товары на складах».",
"- Базовая единица детализации: одна строка = товар, склад и организация на дату.",
"",
"Блок 3. Сводка",
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
`- Позиции с ненулевым остатком: ${formatNumberWithDots(positions.length)}.`,
`- Уникальных товаров: ${formatNumberWithDots(uniqueItems.length)}.`,
`- Уникальных складов: ${formatNumberWithDots(uniqueWarehouses.length)}.`,
`- Суммарное количество: ${formatNumberWithDots(totalQuantity, 3)}.`,
`- Суммарная стоимость: ${formatMoneyRub(totalAmount)}.`,
"",
"Блок 4. Подтвержденные позиции"
];
const directAnswerLine =
positions.length > 0
? `На ${formatDateRu(asOfDate)} на складе подтверждено ${formatNumberWithDots(positions.length)} позиций с остатком на ${formatMoneyRub(totalAmount)}.`
: `На ${formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`;
const lines: string[] = [directAnswerLine];
if (positions.length > 0) {
lines.push("", "Позиции:");
lines.push(
...positions.slice(0, 20).map((item, index) => {
const warehouseLabel = item.warehouse ?? "склад не определен";
@@ -4001,7 +3998,20 @@ export function composeFactualReply(
})
);
} else {
lines.push("- На дату среза товары с ненулевым остатком по счету 41.01 не найдены.");
lines.push("", "Позиции:", "- На дату среза товары с ненулевым остатком по счету 41.01 не найдены.");
}
lines.push(
"",
"Подтверждение:",
`- Дата среза: ${formatDateRu(asOfDate)}.`,
"- Контур: остатки по счету 41.01 «Товары на складах».",
`- Уникальных товаров: ${formatNumberWithDots(uniqueItems.length)}.`,
`- Уникальных складов: ${formatNumberWithDots(uniqueWarehouses.length)}.`,
`- Суммарное количество: ${formatNumberWithDots(totalQuantity, 3)}.`
);
if (rows.length !== positions.length) {
lines.push(`- Строк в подтвержденной выборке: ${formatNumberWithDots(rows.length)}.`);
}
return {
@@ -4021,28 +4031,19 @@ export function composeFactualReply(
const summary = summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен";
const directAnswerLine =
summary.counterparties.length === 1
? `По товару ${itemLabel} документы поступления связаны с поставщиком: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? `По товару ${itemLabel} документы поступления ведут к нескольким поставщикам: ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По товару ${itemLabel} найдены документы поступления, но поставщик не материализован отдельным полем в текущем exact-контуре.`;
const lines: string[] = [
directAnswerLine,
`Собран подтвержденный список документов поступления по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Результат: подтвержденные движения поступления товара на 41.01 по доступным бухгалтерским проводкам.",
"",
"Блок 2. Что учтено",
`- Дата верхней границы: ${formatDateRu(asOfDate)}.`,
"- Контур: движения, где товар поступает на счет 41.01.",
`- Документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`,
`- Операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.`
];
if (summary.counterparties.length > 0) {
lines.push(`- Найденные контрагенты в закупочных движениях: ${summary.counterparties.slice(0, 3).join("; ")}.`);
purchaseRows.length <= 0
? `По позиции ${itemLabel} подтвержденные документы закупки в доступном контуре не найдены.`
: `По позиции ${itemLabel} найдено ${formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${formatDateRu(asOfDate)}.`;
const lines: string[] = [directAnswerLine];
lines.push("", "Подтверждение:");
lines.push(`- Дата верхней границы: ${formatDateRu(asOfDate)}.`);
lines.push(`- Операций поступления в выборке: ${formatNumberWithDots(purchaseRows.length)}.`);
if (summary.counterparties.length === 1) {
lines.push(`- Поставщик: ${summary.counterparties[0]}.`);
} else if (summary.counterparties.length > 1) {
lines.push(`- В закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`);
}
lines.push("", "Блок 3. Документы");
lines.push("", "Документы:");
if (purchaseRows.length > 0) {
lines.push(...formatInventoryTraceRows(purchaseRows, 12));
} else {
@@ -4064,26 +4065,56 @@ export function composeFactualReply(
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен";
const purchaseDateActionFocus = hasInventoryPurchaseDateActionFocus(options.userMessage);
if (purchaseDateActionFocus) {
const firstPurchaseDate = inventoryTraceDateLabel(summary.firstPeriod);
const lastPurchaseDate = inventoryTraceDateLabel(summary.lastPeriod);
const directAnswerLine =
purchaseRows.length <= 0 || !summary.firstPeriod
? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.`
: summary.firstPeriod === summary.lastPeriod
? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.`
: `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`;
const lines: string[] = [directAnswerLine];
if (purchaseRows.length > 0) {
lines.push("", "Подтверждение:");
lines.push(`- Первая подтвержденная дата закупки: ${firstPurchaseDate}.`);
if (summary.firstPeriod !== summary.lastPeriod) {
lines.push(`- Последняя подтвержденная дата закупки: ${lastPurchaseDate}.`);
}
if (summary.counterparties.length === 1) {
lines.push(`- Поставщик в доступном закупочном следе: ${summary.counterparties[0]}.`);
} else if (summary.counterparties.length > 1) {
lines.push(`- В доступном закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`);
}
if (summary.documents.length > 0) {
lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`);
}
if (summary.firstPeriod && asOfDate && summary.firstPeriod < asOfDate) {
lines.push(`- Дата вопроса по остатку: ${formatDateRu(asOfDate)}; дата закупки показана по подтвержденному закупочному следу.`);
}
}
return {
responseType: "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: purchaseRows.length > 0 ? "strong" : "medium",
balance_confirmed: purchaseRows.length > 0
}
};
}
const directAnswerLine =
summary.counterparties.length === 1
? `Товар ${itemLabel} по доступным закупочным движениям связан с поставщиком: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? `По доступным закупочным движениям по товару ${itemLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По товару ${itemLabel} найден закупочный след, но поставщик не материализован отдельным полем в текущем exact-контуре.`;
const lines: string[] = [
directAnswerLine,
`Собран подтвержденный закупочный след по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Результат: показаны подтвержденные закупочные движения на 41.01 по выбранному товару.",
"- Важно: без партионности этот контур не подменяет собой лот-level доказательство происхождения текущего остатка.",
"",
"Блок 2. Сводка",
`- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
`- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`,
`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`
];
const lines: string[] = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`);
lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`);
lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`);
lines.push(`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`);
if (summary.counterparties.length === 1) {
lines.push(`- По доступным закупочным движениям товар связан с поставщиком: ${summary.counterparties[0]}.`);
} else if (summary.counterparties.length > 1) {
@@ -4092,7 +4123,14 @@ export function composeFactualReply(
lines.push("- Закупочные документы найдены, но поставщик не материализован отдельным полем в текущем exact-контуре.");
}
if (summary.documents.length > 0) {
lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 8));
lines.push("", "Опорные документы:", ...formatInventoryTraceRows(purchaseRows, 8));
}
if (purchaseRows.length > 0) {
lines.push(
"",
"Сервисно:",
"- Без партионности этот контур показывает документально наблюдаемый закупочный след, а не лот-level происхождение текущего остатка."
);
}
return {
responseType: purchaseRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY",
@@ -4221,19 +4259,11 @@ export function composeFactualReply(
: summary.counterparties.length > 1
? `По товару ${itemLabel} найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По товару ${itemLabel} покупатель в текущем exact-контуре не материализован.`;
const lines: string[] = [
directAnswerLine,
`Собран подтвержденный след выбытия по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Результат: показаны подтвержденные движения выбытия товара со счета 41.01.",
"",
"Блок 2. Сводка",
`- Первая найденная дата выбытия: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
`- Последняя найденная дата выбытия: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Документов выбытия: ${formatNumberWithDots(summary.documents.length)}.`,
`- Операций выбытия: ${formatNumberWithDots(saleRows.length)}.`
];
const lines: string[] = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Первая найденная дата выбытия: ${inventoryTraceDateLabel(summary.firstPeriod)}.`);
lines.push(`- Последняя найденная дата выбытия: ${inventoryTraceDateLabel(summary.lastPeriod)}.`);
lines.push(`- Документов выбытия: ${formatNumberWithDots(summary.documents.length)}.`);
lines.push(`- Операций выбытия: ${formatNumberWithDots(saleRows.length)}.`);
if (summary.counterparties.length === 1) {
lines.push(`- По доступным движениям товар отгружался покупателю: ${summary.counterparties[0]}.`);
} else if (summary.counterparties.length > 1) {
@@ -4241,7 +4271,7 @@ export function composeFactualReply(
} else if (saleRows.length > 0) {
lines.push("- Документы выбытия найдены, но покупатель не материализован отдельным полем в текущем exact-контуре.");
}
lines.push("", "Блок 3. Документы выбытия");
lines.push("", "Документы выбытия:");
if (saleRows.length > 0) {
lines.push(...formatInventoryTraceRows(saleRows, 12));
} else {
@@ -4269,14 +4299,9 @@ export function composeFactualReply(
purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1
? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.`
: `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`;
const lines: string[] = [
directAnswerLine,
`Собрана документальная цепочка по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
`- Закупочных движений на 41.01: ${formatNumberWithDots(purchaseRows.length)}.`,
`- Движений выбытия со счета 41.01: ${formatNumberWithDots(saleRows.length)}.`
];
const lines: string[] = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Закупочных движений на 41.01: ${formatNumberWithDots(purchaseRows.length)}.`);
lines.push(`- Движений выбытия со счета 41.01: ${formatNumberWithDots(saleRows.length)}.`);
if (purchaseRows.length > 0 && saleRows.length > 0) {
lines.push("- В текущем контуре найдены обе стороны цепочки: поступление и последующее выбытие.");
} else if (purchaseRows.length > 0) {
@@ -4289,7 +4314,7 @@ export function composeFactualReply(
if (purchaseRows.length > 0) {
lines.push(
"",
"Блок 2. Закупка",
"Закупка:",
`- Первая дата: ${inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`,
`- Последняя дата: ${inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`,
...formatInventoryTraceRows(purchaseRows, 6)
@@ -4298,7 +4323,7 @@ export function composeFactualReply(
if (saleRows.length > 0) {
lines.push(
"",
"Блок 3. Выбытие",
"Продажа:",
`- Первая дата: ${inventoryTraceDateLabel(saleSummary.firstPeriod)}.`,
`- Последняя дата: ${inventoryTraceDateLabel(saleSummary.lastPeriod)}.`,
...formatInventoryTraceRows(saleRows, 6)
@@ -334,6 +334,32 @@ function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean {
);
}
function hasInventoryPurchaseDateFollowupCue(text: string): boolean {
return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test(
String(text ?? "")
);
}
function hasBareInventoryPurchaseDateFollowupCue(text: string): boolean {
const normalized = String(text ?? "").trim().toLowerCase();
if (!normalized) {
return false;
}
return /(?:^|\s)(?:когда|а\s+когда|ну\s+когда)(?=$|[\s,.;:!?])/iu.test(normalized) && normalized.split(/\s+/).filter(Boolean).length <= 3;
}
function hasInventorySaleFollowupCue(text: string): boolean {
return /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кому\s+дальше\s+продали|кто\s+купил|buyer|покупател)/iu.test(
String(text ?? "")
);
}
function hasInventoryPurchaseToSaleChainFollowupCue(text: string): boolean {
return /(?:через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale)/iu.test(
String(text ?? "")
);
}
export function hasAddressFollowupContextSignal(text: string): boolean {
const normalized = String(text ?? "").trim();
if (!normalized) {
@@ -564,8 +590,10 @@ function mergeFollowupFilters(
!toNonEmptyString(merged.item) &&
previousItem
) {
merged.item = previousItem;
reasons.push("item_from_followup_context");
if (intent !== "inventory_aging_by_purchase_date") {
merged.item = previousItem;
reasons.push("item_from_followup_context");
}
}
if (sameDateRequested) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
@@ -576,7 +604,11 @@ function mergeFollowupFilters(
}
if (
!sameDateRequested &&
(intent === "inventory_sale_trace_for_item" || intent === "inventory_purchase_to_sale_chain") &&
(intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") &&
!hasExplicitPeriodLiteral(userMessage) &&
!hasExplicitCurrentDateHint(userMessage)
) {
@@ -589,6 +621,15 @@ function mergeFollowupFilters(
reasons.push("as_of_date_from_followup_context");
}
}
if (intent === "inventory_aging_by_purchase_date") {
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(
String(userMessage ?? "")
);
if (toNonEmptyString(merged.item) && !explicitItemMention) {
delete merged.item;
reasons.push("item_cleared_for_stock_slice_aging");
}
}
if (
!sameDateRequested &&
hasFollowupSignalForConfirmed &&
@@ -826,6 +867,64 @@ function deriveIntentWithFollowupContext(
}
}
if (inventorySelectedObjectFollowup && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) {
if (
detectedIntent.intent === "unknown" ||
detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
detectedIntent.intent === previousIntent ||
detectedIntent.intent === "inventory_on_hand_as_of_date"
) {
return {
intent: "inventory_purchase_provenance_for_item",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"]
};
}
}
if (inventorySelectedObjectFollowup && hasInventorySaleFollowupCue(normalizedMessage)) {
if (
detectedIntent.intent === "unknown" ||
detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === previousIntent
) {
return {
intent: "inventory_sale_trace_for_item",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"]
};
}
}
if (inventorySelectedObjectFollowup && hasInventoryPurchaseToSaleChainFollowupCue(normalizedMessage)) {
if (
detectedIntent.intent === "unknown" ||
detectedIntent.intent === "inventory_sale_trace_for_item" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === previousIntent
) {
return {
intent: "inventory_purchase_to_sale_chain",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"]
};
}
}
if (
previousIsInventoryFamily &&
hasFollowupSignal &&
hasBareInventoryPurchaseDateFollowupCue(normalizedMessage) &&
(detectedIntent.intent === "unknown" || detectedIntent.intent === previousIntent)
) {
return {
intent: "inventory_purchase_provenance_for_item",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"]
};
}
if (hasPreviousContract) {
if (detectedIntent.intent === "list_contracts_by_counterparty") {
if (hasBankSignal(normalizedMessage)) {
@@ -2120,6 +2120,9 @@ function readAddressFilterString(addressDebug, key) {
}
return toNonEmptyString(filters[key]);
}
function readAddressInventoryItemFilter(addressDebug) {
return readAddressFilterString(addressDebug, "item");
}
function isAddressLaneDebugPayload(debug) {
if (!debug || typeof debug !== "object") {
return false;
@@ -2473,6 +2476,7 @@ function buildAddressFollowupOffer(addressDebug) {
const anchorType = toNonEmptyString(addressDebug.anchor_type);
const anchorValue = toNonEmptyString(addressDebug.anchor_value_resolved) ??
toNonEmptyString(addressDebug.anchor_value_raw) ??
readAddressInventoryItemFilter(addressDebug) ??
readAddressFilterString(addressDebug, "counterparty") ??
readAddressFilterString(addressDebug, "contract") ??
readAddressFilterString(addressDebug, "account");
@@ -2646,6 +2650,26 @@ function hasShortDebtMirrorFollowupSignal(userMessage) {
/^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєсрѕ(?=$|[\s,.;:!?])/iu.test(sample) ||
/^(?:р°|a|рё|i)\s+(?:рјс\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(sample));
}
function isInventorySelectedObjectIntent(intent) {
return intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain";
}
function hasShortInventoryObjectFollowupSignal(userMessage) {
const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase());
const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
const samples = [rawText, repairedText].filter((item) => item.length > 0);
if (samples.length === 0) {
return false;
}
const minTokens = samples.reduce((min, sample) => Math.min(min, countTokens(sample)), Number.POSITIVE_INFINITY);
if (minTokens > 8) {
return false;
}
return samples.some((sample) => /^(?:кто|когда|документы|сумма|поставщик|покупатель)(?:\?)?$/iu.test(sample) ||
/^(?:когда\s+(?:примерно\s+)?купили(?:\s+ее)?|каким\s+документом|покажи\s+документы|по\s+каким\s+документам|все\s+закупки|все\s+поступления|кому\s+(?:мы\s+)?продали|кто\s+купил|цепочка|путь\s+товара)(?:\?)?$/iu.test(sample));
}
function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase());
if (!normalized || countTokens(normalized) > 10) {
@@ -2676,14 +2700,18 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
(isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) ||
(toNonEmptyString(alternateMessage) ? isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false));
const sourceIntentHint = toNonEmptyString(previousAddressDebug?.detected_intent);
const inventoryShortFollowupPrimary = isInventorySelectedObjectIntent(sourceIntentHint) && hasShortInventoryObjectFollowupSignal(userMessage);
const inventoryShortFollowupAlternate = isInventorySelectedObjectIntent(sourceIntentHint) && toNonEmptyString(alternateMessage)
? hasShortInventoryObjectFollowupSignal(String(alternateMessage ?? ""))
: false;
const debtRoleSwapPrimary = sourceIntentHint ? resolveDebtRoleSwapFollowupIntent(userMessage, sourceIntentHint) : null;
const debtRoleSwapAlternate = sourceIntentHint && toNonEmptyString(alternateMessage)
? resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint)
: null;
const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null;
const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary);
const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || inventoryShortFollowupPrimary;
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate)
? hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || inventoryShortFollowupAlternate
: false;
const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage)
@@ -2723,6 +2751,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
let previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
let previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
toNonEmptyString(previousAddressDebug.anchor_value_raw) ??
readAddressFilterString(previousAddressDebug, "item") ??
readAddressFilterString(previousAddressDebug, "counterparty") ??
readAddressFilterString(previousAddressDebug, "account") ??
readAddressFilterString(previousAddressDebug, "contract");
@@ -3924,6 +3953,12 @@ export function resolveAssistantOrchestrationDecision(input) {
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage);
const protectedInventoryShortFollowup = Boolean(followupContext &&
isInventorySelectedObjectIntent(toNonEmptyString(followupContext.previous_intent)) &&
(hasShortInventoryObjectFollowupSignal(rawUserMessage) ||
hasShortInventoryObjectFollowupSignal(repairedRawUserMessage) ||
hasShortInventoryObjectFollowupSignal(effectiveAddressUserMessage) ||
hasShortInventoryObjectFollowupSignal(repairedEffectiveAddressUserMessage)));
const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal;
const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery &&
!capabilityMetaQuery &&
@@ -3933,7 +3968,8 @@ export function resolveAssistantOrchestrationDecision(input) {
intentResolution.intent === "unknown");
const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate &&
deterministicNonDomainGuard &&
(llmFirstUnsupportedCandidate || llmContractMode === null));
(llmFirstUnsupportedCandidate || llmContractMode === null) &&
!protectedInventoryShortFollowup);
const hardMetaMode = dataScopeMetaQuery
? "data_scope"
: capabilityMetaQuery && !dataRetrievalSignal
@@ -0,0 +1,161 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const { executeAddressMcpQueryMock } = vi.hoisted(() => ({
executeAddressMcpQueryMock: vi.fn()
}));
vi.mock("../src/services/addressMcpClient", async () => {
const actual = await vi.importActual<typeof import("../src/services/addressMcpClient")>(
"../src/services/addressMcpClient"
);
return {
...actual,
executeAddressMcpQuery: executeAddressMcpQueryMock
};
});
import { AddressQueryService } from "../src/services/addressQueryService";
afterEach(() => {
executeAddressMcpQueryMock.mockReset();
});
describe("inventory purchase-date selected-object follow-up", () => {
it("routes short 'когда' follow-up to purchase provenance and reuses the active item", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2020-02-11T00:00:00Z",
Registrator: "Поступление товаров и услуг 00000000077 от 11.02.2020 0:00:00",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 165.83,
SubcontoDt1: "Кромка с клеем 33 альмяндин 137 м",
SubcontoDt3: "Основной склад",
SubcontoKt1: "Торговый дом \\Союз МСК\\",
SubcontoKt2: "Договор поставки № 12 от 01.02.2020",
Organization: "ООО \\Альтернатива Плюс\\"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle("когда", {
followupContext: {
previous_intent: "inventory_purchase_provenance_for_item",
previous_filters: {
item: "Кромка с клеем 33 альмяндин 137 м",
warehouse: "Основной склад",
as_of_date: "2020-03-31"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
}
});
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.debug.extracted_filters?.item).toBe("Кромка с клеем 33 альмяндин 137 м");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31");
const replyLines = String(result?.reply_text ?? "").split("\n");
expect(replyLines[0]).toContain("11.02.2020");
expect(replyLines[0]).toContain("Кромка");
expect(String(result?.reply_text ?? "")).not.toContain("Блок 1");
expect(String(result?.reply_text ?? "")).toContain("Подтверждение");
expect(result?.debug.reasons).toContain("address_followup_context_applied");
});
it("routes bare 'когда' follow-up to purchase provenance with the carried active item", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2019-02-12T00:00:00Z",
Registrator: "Поступление товаров и услуг 00000000003 от 12.02.2019 0:00:00",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 3724.17,
SubcontoDt1: "Столешница 600*3050*26 дуб ниагара",
SubcontoDt3: "Основной склад",
SubcontoKt1: "Торговый дом \\Союз МСК\\",
SubcontoKt2: "Договор поставки № 12 от 01.02.2019",
Organization: "ООО \\Альтернатива Плюс\\"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle("когда", {
followupContext: {
previous_intent: "inventory_purchase_provenance_for_item",
previous_filters: {
item: "Столешница 600*3050*26 дуб ниагара",
warehouse: "Основной склад",
as_of_date: "2019-03-31"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
}
});
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.debug.extracted_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2019-03-31");
expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("12.02.2019");
expect(String(result?.reply_text ?? "")).not.toContain("Блок 1");
});
it("routes 'когда примерно мы купили' follow-up to compact purchase-date answer with the carried item", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2020-02-11T00:00:00Z",
Registrator: "Поступление товаров и услуг 00000000077 от 11.02.2020 0:00:00",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 833.33,
SubcontoDt1: "Четки Пост (84*117)",
SubcontoDt3: "Основной склад",
SubcontoKt1: "Торговый дом \\Союз МСК\\",
SubcontoKt2: "Договор поставки № 12 от 01.02.2020",
Organization: "ООО \\Альтернатива Плюс\\"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle("когда примерно мы купили?", {
followupContext: {
previous_intent: "inventory_purchase_provenance_for_item",
previous_filters: {
item: "Четки Пост (84*117)",
warehouse: "Основной склад",
as_of_date: "2020-03-31"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
}
});
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.debug.extracted_filters?.item).toBe("Четки Пост (84*117)");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31");
expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("11.02.2020");
expect(String(result?.reply_text ?? "")).toContain("Подтверждение");
expect(String(result?.reply_text ?? "")).not.toContain("Блок 1");
});
});
@@ -206,4 +206,51 @@ describe("inventory selected-object follow-up", () => {
expect(result?.debug.extracted_filters?.as_of_date).toBe("2019-03-31");
expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000077");
});
it("routes buyer follow-up over the same selected item into sale trace instead of replaying provenance", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2020-04-12T00:00:00Z",
Registrator: "Реализация товаров и услуг 00000000112 от 12.04.2020 0:00:00",
AccountDt: "90.02",
AccountKt: "41.01",
Amount: 833.33,
SubcontoKt1: "Четки Пост (84*117)",
SubcontoKt3: "Основной склад",
SubcontoDt1: "ИП Покупатель",
Organization: "ООО \\Альтернатива Плюс\\"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle("кому в итоге мы продали этот товар?", {
followupContext: {
previous_intent: "inventory_purchase_provenance_for_item",
previous_filters: {
as_of_date: "2020-03-31",
period_from: "2020-03-01",
period_to: "2020-03-31",
item: "Четки Пост (84*117)",
warehouse: "Основной склад"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
}
});
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_LIST");
expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item");
expect(result?.debug.selected_recipe).toBe("address_inventory_sale_trace_for_item_v1");
expect(result?.debug.extracted_filters?.item).toBe("Четки Пост (84*117)");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31");
expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("ИП Покупатель");
expect(String(result?.reply_text ?? "")).toContain("Документы выбытия");
});
});
@@ -111,4 +111,37 @@ describe("address navigation state", () => {
expect(evolved.session_context.date_scope.period_from).toBe("2020-01-01");
expect(evolved.session_context.date_scope.period_to).toBe("2020-12-31");
});
it("captures item focus from inventory answers when no anchor is materialized", () => {
const base = createEmptyAddressNavigationState("asst-4", "2026-04-12T10:00:00.000Z");
const assistantItem = {
message_id: "msg-a3",
session_id: "asst-4",
role: "assistant",
text: "Собран подтвержденный закупочный след по товару Диван трехместный до 14.04.2026.\n\n1. Авансовый отчет 00000000004 от 24.08.2018 12:00:04 | дата: 24.08.2018 | сумма: 34.490,00 ₽ | склад: Основной склад",
reply_type: "factual",
created_at: "2026-04-12T10:03:00.000Z",
trace_id: "address-789",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_purchase_provenance_for_item",
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
extracted_filters: {
item: "Диван трехместный",
warehouse: "Основной склад",
as_of_date: "2026-04-14"
},
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.session_context.active_focus_object?.label).toBe("Диван трехместный");
expect(evolved.session_context.active_focus_object?.provenance_result_set_id).toBe("rs-msg-a3");
});
});
@@ -293,7 +293,7 @@ describe("address query shape classifier", () => {
useRubCurrency: true
}
);
expect(reply.text.split("\n")[0]).toContain("поставщиком");
expect(reply.text.split("\n")[0]).toContain("документов закупки");
expect(reply.text).toContain("Шкаф картотечный");
expect(reply.text).toContain("Поступление товаров и услуг 0001");
expect(reply.semantics?.result_mode).toBe("confirmed_balance");
@@ -321,7 +321,8 @@ describe("address query shape classifier", () => {
}
);
expect(reply.text.split("\n")[0]).toContain("поставщиком");
expect(reply.text).toContain("закупочный след");
expect(reply.text).toContain("Подтверждение");
expect(reply.text).not.toContain("Блок 1");
expect(reply.text).toContain("Гамма-мебель, ООО");
expect(reply.semantics?.balance_confirmed).toBe(true);
});
@@ -348,7 +349,8 @@ describe("address query shape classifier", () => {
}
);
expect(reply.text.split("\n")[0]).toContain("покупатель");
expect(reply.text).toContain("след выбытия");
expect(reply.text).toContain("Документы выбытия");
expect(reply.text).not.toContain("Блок 1");
expect(reply.text).toContain("Реализация товаров и услуг 0007");
expect(reply.text).toContain("Департамент капитального ремонта города Москвы");
});
@@ -385,7 +387,7 @@ describe("address query shape classifier", () => {
useRubCurrency: true
}
);
expect(reply.text).toContain("документальная цепочка");
expect(reply.text.split("\n")[0]).toContain("цепочка поставки и продажи");
expect(reply.text).toContain("Поступление товаров и услуг 0001");
expect(reply.text).toContain("Реализация товаров и услуг 0007");
expect(reply.semantics?.result_mode).toBe("confirmed_balance");
@@ -3968,10 +3970,28 @@ describe("address decompose stage follow-up carryover", () => {
expect(result?.filters.extracted_filters.as_of_date).toBe("2019-03-31");
expect(
result?.baseReasons?.includes("intent_adjusted_to_inventory_followup_context") ||
result?.intent.reasons.includes("inventory_selected_object_purchase_documents_signal_detected")
result?.intent.reasons.includes("inventory_selected_object_purchase_documents_signal_detected")
).toBe(true);
});
it("promotes conversational buyer follow-up into inventory sale trace with inherited date context", () => {
const result = runAddressDecomposeStage("кому в итоге мы продали этот товар?", {
previous_intent: "inventory_purchase_provenance_for_item",
previous_filters: {
as_of_date: "2020-03-31",
period_from: "2020-03-01",
period_to: "2020-03-31",
item: "Четки Пост (84*117)"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_sale_trace_for_item");
expect(result?.filters.extracted_filters.item).toBe("Четки Пост (84*117)");
expect(result?.filters.extracted_filters.as_of_date).toBe("2020-03-31");
});
it("keeps slang all-customers-all-time wording in address lane via resolved intent fallback", () => {
const result = runAddressDecomposeStage("выведи всех заков за все время", null);
expect(result).not.toBeNull();
@@ -4791,8 +4811,9 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("Собран подтвержденный срез товаров на складах");
expect(reply.text.split("\n")[0]).toContain("На 31.03.2020 на складе подтверждено");
expect(reply.text).toContain("Контур: остатки по счету 41.01");
expect(reply.text).not.toContain("Блок 1");
expect(reply.text).toContain("Шкаф картотечный");
expect(reply.text).toContain("Основной склад");
expect(reply.semantics?.result_mode).toBe("confirmed_balance");
@@ -4828,6 +4849,11 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
});
it("routes conversational buyer wording to inventory sale trace intent", () => {
const result = resolveAddressIntent("Кому в итоге мы продали товар Шкаф картоотечный?");
expect(result.intent).toBe("inventory_sale_trace_for_item");
});
it("keeps inventory provenance wording out of inventory-on-hand routing", () => {
const result = resolveAddressIntent("От кого куплен товар Шкаф картоотечный и когда был куплен?");
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
@@ -278,6 +278,101 @@ describe("assistant address follow-up carryover", () => {
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats bare 'когда' as a selected-item inventory follow-up for the active provenance object", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "когда";
const provenanceResult = {
handled: true,
reply_text: "Товар Столешница 600*3050*26 дуб ниагара по доступным закупочным движениям связан с поставщиком: Торговый дом \\Союз\\.",
reply_type: "factual",
response_type: "FACTUAL_SUMMARY",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_purchase_provenance_for_item",
detected_intent_confidence: "high",
extracted_filters: {
item: "Столешница 600*3050*26 дуб ниагара",
warehouse: "Основной склад",
as_of_date: "2019-03-31"
},
missing_required_filters: [],
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
anchor_type: "unknown",
anchor_value_raw: null,
anchor_value_resolved: null,
reasons: ["address_action_detected", "address_entity_detected"],
dialog_continuation_contract_v2: {
decision: "continue_previous"
}
}
} as any;
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && !options?.followupContext) {
return null;
}
if (message === followupMessage && options?.followupContext) {
return {
...provenanceResult,
reply_text: "Позиция Столешница 600*3050*26 дуб ниагара куплена 12.02.2019.\n\nПодтверждение:\n- Первый подтверждающий документ: Поступление товаров и услуг 00000000003 от 12.02.2019.",
debug: {
...provenanceResult.debug,
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
};
}
return provenanceResult;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-when-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-inventory-provenance-seed",
session_id: sessionId,
role: "assistant",
text: provenanceResult.reply_text,
reply_type: provenanceResult.reply_type,
created_at: "2026-04-14T18:00:00.000Z",
trace_id: "address-seed",
debug: provenanceResult.debug
} as any);
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe(followupMessage);
expect(calls[0].options?.followupContext?.previous_intent).toBe("inventory_purchase_provenance_for_item");
expect(calls[0].options?.followupContext?.previous_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара");
expect(calls[0].options?.followupContext?.previous_filters?.warehouse).toBe("Основной склад");
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2019-03-31");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats typo imperative 'показывыай' as implicit continuation and switches to suggested follow-up intent", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи документы по свк за 2020";
@@ -259,7 +259,7 @@ describe("assistant orchestration contract", () => {
expect(decision.runAddressLane).toBe(true);
expect(decision.toolGateDecision).toBe("run_address_lane");
expect(decision.toolGateReason).toBe("address_mode_classifier_detected");
expect(["address_mode_classifier_detected", "address_intent_resolver_detected"]).toContain(String(decision.toolGateReason));
expect(decision.livingMode).toBe("address_data");
expect(decision.livingReason).toBe("address_lane_triggered");
});
@@ -289,7 +289,44 @@ describe("assistant orchestration contract", () => {
expect(decision.runAddressLane).toBe(true);
expect(decision.toolGateDecision).toBe("run_address_lane");
expect(decision.toolGateReason).toBe("address_mode_classifier_detected");
expect(["address_mode_classifier_detected", "address_intent_resolver_detected"]).toContain(String(decision.toolGateReason));
expect(decision.livingMode).toBe("address_data");
expect(decision.livingReason).toBe("address_lane_triggered");
});
it("keeps short inventory follow-up 'когда' in address lane when a selected-item provenance context exists", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "когда",
effectiveAddressUserMessage: "когда",
followupContext: {
previous_intent: "inventory_purchase_provenance_for_item",
previous_filters: {
item: "Столешница 600*3050*26 дуб ниагара",
warehouse: "Основной склад",
as_of_date: "2019-03-31"
}
},
llmPreDecomposeMeta: {
applied: false,
reason: "normalized_fragment_rejected_semantic_guard",
llmCanonicalCandidateDetected: true,
predecomposeContract: {
mode: "unsupported",
mode_confidence: "low",
intent: "unknown",
intent_confidence: "low"
},
semanticExtractionContract: {
valid: false,
apply_canonical_recommended: false,
reason_codes: ["unsupported_low_confidence_contract"]
}
} as any,
useMock: false
});
expect(decision.runAddressLane).toBe(true);
expect(decision.toolGateDecision).toBe("run_address_lane");
expect(decision.livingMode).toBe("address_data");
expect(decision.livingReason).toBe("address_lane_triggered");
});