АРЧ - Склад: сохранять полный item-anchor в selected-object sale trace и закрепить buyer follow-up регрессиями

This commit is contained in:
dctouch 2026-04-15 17:57:05 +03:00
parent 7a6d8eb070
commit f911f9893b
27 changed files with 1539 additions and 180 deletions

View File

@ -3,6 +3,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.isLowQualityInventoryItemAnchorValue = isLowQualityInventoryItemAnchorValue;
exports.isInventoryItemAnchorDegradation = isInventoryItemAnchorDegradation;
exports.extractSelectedObjectQuotedValue = extractSelectedObjectQuotedValue;
exports.extractAddressFilters = extractAddressFilters;
const iconv_lite_1 = __importDefault(require("iconv-lite"));
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
@ -836,16 +839,47 @@ function isLowQualityInventoryItemAnchorValue(rawValue) {
return true;
}
const lowQualityTokens = new Set([
"в",
"во",
"на",
"по",
"у",
"от",
"из",
"для",
"и",
"или",
"это",
"этот",
"эту",
"его",
"ее",
"её",
"итог",
"итоге",
"итогу",
"сейчас",
"лежат",
"лежит",
"лежали",
"был",
"была",
"было",
"были",
"куплен",
"куплена",
"куплены",
"куплено",
"продан",
"продана",
"проданы",
"продано",
"продали",
"реализован",
"реализована",
"реализованы",
"реализовано",
"реализовали",
"документам",
"документами",
"документы",
@ -865,6 +899,37 @@ function isLowQualityInventoryItemAnchorValue(rawValue) {
.filter((token) => !lowQualityTokens.has(token));
return meaningfulTokens.length === 0;
}
function normalizeInventoryItemAnchorForComparison(rawValue) {
return cleanupAnchorValue(rawValue)
.trim()
.toLowerCase()
.replace(/С/g, "Рµ")
.replace(/\s+/g, " ");
}
function tokenizeInventoryItemAnchorForComparison(rawValue) {
return normalizeInventoryItemAnchorForComparison(rawValue)
.split(/[^a-zа-я0-9]+/iu)
.map((token) => token.trim())
.filter(Boolean);
}
function isInventoryItemAnchorDegradation(sourceValue, candidateValue) {
const sourceNormalized = normalizeInventoryItemAnchorForComparison(sourceValue);
const candidateNormalized = normalizeInventoryItemAnchorForComparison(candidateValue);
if (!sourceNormalized || !candidateNormalized || sourceNormalized === candidateNormalized) {
return false;
}
if (isLowQualityInventoryItemAnchorValue(candidateNormalized)) {
return true;
}
if (sourceNormalized.includes(candidateNormalized) && candidateNormalized.length < sourceNormalized.length) {
return true;
}
const sourceTokens = tokenizeInventoryItemAnchorForComparison(sourceNormalized);
const candidateTokens = tokenizeInventoryItemAnchorForComparison(candidateNormalized);
return (candidateTokens.length > 0 &&
candidateTokens.length < sourceTokens.length &&
candidateTokens.every((token) => sourceTokens.includes(token)));
}
function cleanupInventoryItemAnchorValue(value) {
return String(value ?? "")
.replace(/^['"«»“”„`]+|['"«»“”„`]+$/gu, "")
@ -886,6 +951,31 @@ function trimInventoryItemAnchorTail(rawValue) {
}
return cleanupInventoryItemAnchorValue(value);
}
function extractQuotedAnchorValue(text) {
const patterns = [/[«"]([^«»"\r\n]+)[»"]/u, /'([^'\r\n]+)'/u];
for (const pattern of patterns) {
const match = String(text ?? "").match(pattern);
const candidate = cleanupInventoryItemAnchorValue(String(match?.[1] ?? ""));
if (candidate) {
return candidate;
}
}
return undefined;
}
function extractUnicodeQuotedAnchorValue(text) {
const patterns = [
/(?:\u00AB|\u0412\u00AB|")([^\u00AB\u00BB"\r\n]+?)(?:\u00BB|\u0412\u00BB|")/u,
/'([^'\r\n]+)'/u
];
for (const pattern of patterns) {
const match = String(text ?? "").match(pattern);
const candidate = cleanupInventoryItemAnchorValue(String(match?.[1] ?? ""));
if (candidate) {
return candidate;
}
}
return undefined;
}
function extractSelectedObjectQuotedValue(text) {
const patterns = [
/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*[«"]([^»"\r\n]+)[»"]/iu,
@ -898,7 +988,7 @@ function extractSelectedObjectQuotedValue(text) {
return candidate;
}
}
return undefined;
return extractUnicodeQuotedAnchorValue(text) ?? extractQuotedAnchorValue(text);
}
function extractInventoryItemFromSelectedObject(text) {
const selectedObject = extractSelectedObjectQuotedValue(text);
@ -923,6 +1013,10 @@ function extractInventoryItemAnchor(text) {
if (selectedObjectItem) {
return selectedObjectItem;
}
const quotedItem = extractUnicodeQuotedAnchorValue(text) ?? extractQuotedAnchorValue(text);
if (quotedItem && !isLowQualityInventoryItemAnchorValue(quotedItem)) {
return quotedItem;
}
const patterns = [
/(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s*[«"']([^«»"'?\r\n]+)[»"'](?=$|[\s,.;:!?])/iu,
/(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s+([^\r\n,.;:!?]+?)(?=\s+(?:на|по|у|от|из|для|и|когда|через|сейчас|еще|ещё|котор|которые|который|покупателю|поставщика|поставщику|за|в)\b|[:?]|$)/iu

View File

@ -1,6 +1,7 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveAddressIntent = resolveAddressIntent;
const inventoryLifecycleCueHelpers_1 = require("./inventoryLifecycleCueHelpers");
const RECEIVABLES_STRONG = [
"кто должен нам",
"кто нам должен",
@ -1345,21 +1346,19 @@ function hasSelectedObjectInventoryCue(text) {
return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+нему|по\s+ней|по\s+нему\s+же|по\s+ней\s+же|selected\s+object)/iu.test(text);
}
function hasSelectedObjectInventoryProvenanceSignal(text) {
return (hasSelectedObjectInventoryCue(text) &&
/(?:кто\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+куплено|supplier|vendor|поставщик)/iu.test(text));
return hasSelectedObjectInventoryCue(text) && (0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(text);
}
function hasSelectedObjectInventoryPurchaseDocumentsSignal(text) {
return (hasSelectedObjectInventoryCue(text) &&
/(?:по\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(text));
}
function hasSelectedObjectInventorySaleTraceSignal(text) {
return (hasSelectedObjectInventoryCue(text) &&
/(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|куда\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|куда\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale)/iu.test(text));
return hasSelectedObjectInventoryCue(text) && (0, inventoryLifecycleCueHelpers_1.hasInventorySaleCue)(text);
}
function hasInventoryProvenanceSignalV2(text) {
const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:ок|ки)|склад)/iu.test(text);
const hasSupplierCue = /(?:от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставщик|supplier|vendor)/iu.test(text);
const hasPurchaseCue = /(?:куплен(?:ы|а|о)?|закупк|происхождени|откуда|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:ы|а)?|purchase\s+provenance|purchase\s+date)/iu.test(text);
const hasSupplierCue = (0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(text) || /кем\s+поставлен/iu.test(text);
const hasPurchaseCue = /(?:куплен(?:ы|а|о)?|закупк|происхождени|откуда|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:ы|а)?|purchase\s+provenance|purchase\s+date)/iu.test(text) || (0, inventoryLifecycleCueHelpers_1.hasInventoryPurchaseStem)(text);
return hasItemCue && hasSupplierCue && hasPurchaseCue;
}
function hasInventoryPurchaseDateSignal(text) {

View File

@ -1,6 +1,7 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.detectAddressQuestionMode = detectAddressQuestionMode;
const inventoryLifecycleCueHelpers_1 = require("./inventoryLifecycleCueHelpers");
const ADDRESS_ACTION_TOKENS = [
"show",
"list",
@ -277,7 +278,10 @@ function hasSelectedObjectInventoryFollowupSignal(text) {
if (!/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)/iu.test(text)) {
return false;
}
return /(?:у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:поставил|продал)|кому\s+(?:продали|реализовали)|когда\s+(?:примерно\s+)?купили|по\s+каким\s+документам\s+.*купили)/iu.test(text);
return ((0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(text) ||
(0, inventoryLifecycleCueHelpers_1.hasInventorySaleCue)(text) ||
/(?:кто\s+(?:поставил|продал)|по\s+каким\s+документам\s+.*купили)/iu.test(text) ||
(/\bкогда\b/iu.test(text) && (0, inventoryLifecycleCueHelpers_1.hasInventoryPurchaseStem)(text)));
}
function hasDocsOrBankSignal(text) {
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|поступлен|списан|транзак|transactions?|bank\s+ops|bank\s+operations?)/iu.test(text);

View File

@ -1611,15 +1611,11 @@ function shouldBoostAutoBroadenedLimit(intent) {
intent === "inventory_aging_by_purchase_date");
}
function shouldClearAsOfDateForHistoryRecovery(intent) {
return (intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
return (intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain");
}
function shouldDetachLifecycleExecutionFromSnapshotContext(intent, reasons) {
if (intent !== "inventory_purchase_provenance_for_item" &&
intent !== "inventory_purchase_documents_for_item" &&
intent !== "inventory_sale_trace_for_item" &&
if (intent !== "inventory_sale_trace_for_item" &&
intent !== "inventory_purchase_to_sale_chain") {
return false;
}

View File

@ -42,6 +42,42 @@ __WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Движения.Период __ORDER_DIRECTION__
`;
const INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
Товары.Ссылка.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"41.01" КАК СчетКт,
Товары.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация,
Товары.Количество КАК Количество
ИЗ
Документ.РеализацияТоваровУслуг.Товары КАК Товары
__WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Товары.Ссылка.Дата __ORDER_DIRECTION__
`;
const INVENTORY_PURCHASE_DOCUMENTS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
Товары.Ссылка.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор,
"41.01" КАК СчетДт,
"" КАК СчетКт,
Товары.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация,
Товары.Количество КАК Количество
ИЗ
Документ.ПоступлениеТоваровУслуг.Товары КАК Товары
__WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Товары.Ссылка.Дата __ORDER_DIRECTION__
`;
const PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период,
@ -938,15 +974,6 @@ function toDateTimeExpr(isoDate, endOfDay) {
function toQueryStringLiteral(value) {
return String(value ?? "").replace(/"/g, '""');
}
function buildOrganizationPresentationCondition(filters, fieldPath) {
const organization = typeof filters.organization === "string" && filters.organization.trim().length > 0
? filters.organization.trim()
: "";
if (!organization) {
return null;
}
return `ПРЕДСТАВЛЕНИЕ(${fieldPath}) = "${toQueryStringLiteral(organization)}"`;
}
function buildWhereClause(filters, fieldPath, extraConditions = []) {
const periodFromExpr = typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, false)
@ -1087,10 +1114,40 @@ function buildInventoryMovementQuery(filters, resolvedLimit, side) {
: side === "kt"
? creditPredicate
: `(${debitPredicate} ИЛИ ${creditPredicate})`;
const organizationCondition = buildOrganizationPresentationCondition(filters, "Движения.Организация");
return INVENTORY_MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", [inventoryCondition, organizationCondition].filter((item) => Boolean(item))))
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", [inventoryCondition]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
}
function buildInventoryItemReferenceCondition(filters, fieldPaths) {
const item = typeof filters.item === "string" ? filters.item.trim() : "";
if (!item) {
return null;
}
const escapedItem = toQueryStringLiteral(item);
const referenceSubquery = `(ВЫБРАТЬ Номенклатура.Ссылка ИЗ Справочник.Номенклатура КАК Номенклатура ` +
`ГДЕ Номенклатура.Наименование = "${escapedItem}")`;
const clauses = fieldPaths
.map((fieldPath) => String(fieldPath ?? "").trim())
.filter((fieldPath) => fieldPath.length > 0)
.map((fieldPath) => `${fieldPath} В ${referenceSubquery}`);
if (clauses.length === 0) {
return null;
}
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function buildInventorySaleDocumentQuery(filters, resolvedLimit) {
const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]);
return INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Товары.Ссылка.Дата", ['Товары.Ссылка.Проведен = ИСТИНА', itemCondition].filter((item) => Boolean(item))))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
}
function buildInventoryPurchaseDocumentQuery(filters, resolvedLimit) {
const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]);
return INVENTORY_PURCHASE_DOCUMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Товары.Ссылка.Дата", ['Товары.Ссылка.Проведен = ИСТИНА', itemCondition].filter((item) => Boolean(item))))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
}
function shouldBoostLimitForAllTimeCounterparty(filters) {
@ -1269,13 +1326,13 @@ function buildAddressRecipePlan(recipe, filters) {
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: recipe.query_template === "inventory_purchase_provenance_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_documents_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_supplier_stock_overlap_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_sale_trace_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "kt")
? buildInventorySaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "either")
: recipe.query_template === "inventory_aging_by_purchase_date_profile"

View File

@ -3140,6 +3140,7 @@ function composeFactualReply(intent, rows, options = {}) {
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен";
const boundedAsOfLabel = asOfDate ? formatDateRu(asOfDate) : null;
const purchaseDateActionFocus = hasInventoryPurchaseDateActionFocus(options.userMessage);
if (purchaseDateActionFocus) {
const firstPurchaseDate = inventoryTraceDateLabel(summary.firstPeriod);
@ -3148,7 +3149,9 @@ function composeFactualReply(intent, rows, options = {}) {
? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.`
: summary.firstPeriod === summary.lastPeriod
? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.`
: `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`;
: boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`
: `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`;
const lines = [directAnswerLine];
if (purchaseRows.length > 0) {
lines.push("", "Подтверждение:");
@ -3165,8 +3168,11 @@ function composeFactualReply(intent, rows, options = {}) {
if (summary.documents.length > 0) {
lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`);
}
if (summary.firstPeriod && asOfDate && summary.firstPeriod < asOfDate) {
lines.push(`- Дата вопроса по остатку: ${formatDateRu(asOfDate)}; дата закупки показана по подтвержденному закупочному следу.`);
if (boundedAsOfLabel) {
lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.counterparties.length > 1) {
lines.push(`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`);
}
}
return {
@ -3179,33 +3185,51 @@ function composeFactualReply(intent, rows, options = {}) {
}
};
}
const directAnswerLine = summary.counterparties.length === 1
? `Товар ${itemLabel} по доступным закупочным движениям связан с поставщиком: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? `По доступным закупочным движениям по товару ${itemLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По товару ${itemLabel} найден закупочный след, но поставщик не материализован отдельным полем в текущем exact-контуре.`;
const directAnswerLine = purchaseRows.length <= 0
? boundedAsOfLabel
? `По позиции ${itemLabel} подтвержденные закупочные документы до ${boundedAsOfLabel} не найдены.`
: `По позиции ${itemLabel} подтвержденные закупочные документы не найдены.`
: summary.counterparties.length === 1
? boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден поставщик: ${summary.counterparties[0]}.`
: `По позиции ${itemLabel} подтвержден поставщик: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По позиции ${itemLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.`
: boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.`
: `По позиции ${itemLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.`;
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]}.`);
if (purchaseRows.length > 0) {
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) {
lines.push(`- Поставщики в найденных документах: ${summary.counterparties.slice(0, 6).join("; ")}.`);
}
else {
lines.push("- Закупочные документы найдены, но поставщик в них не выделен отдельным полем.");
}
if (boundedAsOfLabel) {
lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.counterparties.length > 1) {
lines.push(`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`);
}
}
else if (summary.counterparties.length > 1) {
lines.push(`- По доступным закупочным движениям найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`);
}
else if (purchaseRows.length > 0) {
lines.push("- Закупочные документы найдены, но поставщик не материализован отдельным полем в текущем exact-контуре.");
else if (boundedAsOfLabel) {
lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.documents.length > 0) {
lines.push("", "Опорные документы:", ...formatInventoryTraceRows(purchaseRows, 8));
}
if (purchaseRows.length > 0) {
lines.push("", "Сервисно:", "- Без партионности этот контур показывает документально наблюдаемый закупочный след, а не лот-level происхождение текущего остатка.");
}
return {
responseType: purchaseRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY",
responseType: "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",

View File

@ -1,11 +1,18 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.hasInventorySupplierFollowupCue = hasInventorySupplierFollowupCue;
exports.hasInventoryPurchaseDocumentsFollowupCue = hasInventoryPurchaseDocumentsFollowupCue;
exports.hasInventoryPurchaseDateFollowupCue = hasInventoryPurchaseDateFollowupCue;
exports.hasBareInventoryPurchaseDateFollowupCue = hasBareInventoryPurchaseDateFollowupCue;
exports.hasInventorySaleFollowupCue = hasInventorySaleFollowupCue;
exports.hasInventoryPurchaseToSaleChainFollowupCue = hasInventoryPurchaseToSaleChainFollowupCue;
exports.hasAddressFollowupContextSignal = hasAddressFollowupContextSignal;
exports.runAddressDecomposeStage = runAddressDecomposeStage;
const addressQueryClassifier_1 = require("../addressQueryClassifier");
const addressQueryShapeClassifier_1 = require("../addressQueryShapeClassifier");
const addressIntentResolver_1 = require("../addressIntentResolver");
const addressFilterExtractor_1 = require("../addressFilterExtractor");
const inventoryLifecycleCueHelpers_1 = require("../inventoryLifecycleCueHelpers");
const semanticHintOverlay_1 = require("./semanticHintOverlay");
function hasExplicitPeriodWindow(filters) {
return ((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
@ -402,13 +409,14 @@ function hasSelectedObjectInventorySignal(text) {
return /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(String(text ?? ""));
}
function hasInventorySupplierFollowupCue(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+куплено|supplier|vendor|поставщик)/iu.test(String(text ?? ""));
return (0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(String(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 ?? ""));
const value = String(text ?? "");
return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test(value) || (/когда/iu.test(value) && (0, inventoryLifecycleCueHelpers_1.hasInventoryPurchaseStem)(value));
}
function hasBareInventoryPurchaseDateFollowupCue(text) {
const normalized = String(text ?? "").trim().toLowerCase();
@ -418,7 +426,7 @@ function hasBareInventoryPurchaseDateFollowupCue(text) {
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+(?:в\s+итоге\s+)?(?:мы\s+)?продали|куда\s+ушла\s+позиция|куда\s+ушел\s+товар|кто\s+купил|buyer|покупател)/iu.test(String(text ?? ""));
return (0, inventoryLifecycleCueHelpers_1.hasInventorySaleCue)(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 ?? ""));
@ -608,12 +616,34 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") &&
!toNonEmptyString(merged.item)) {
intent === "inventory_aging_by_purchase_date")) {
const inheritedItem = previousItem ?? previousAnchorItem;
if (inheritedItem && intent !== "inventory_aging_by_purchase_date") {
const explicitQuotedItem = toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(userMessage));
const currentItem = toNonEmptyString(merged.item);
const shouldAdoptExplicitQuotedItem = Boolean(explicitQuotedItem) &&
(!currentItem ||
currentItem !== explicitQuotedItem ||
(0, addressFilterExtractor_1.isInventoryItemAnchorDegradation)(explicitQuotedItem ?? "", currentItem ?? ""));
if (explicitQuotedItem && shouldAdoptExplicitQuotedItem) {
merged.item = explicitQuotedItem;
reasons.push(currentItem ? "item_replaced_from_explicit_quote" : "item_from_explicit_quote");
}
const effectiveCurrentItem = toNonEmptyString(merged.item);
const hasExplicitDifferentQuotedItem = Boolean(explicitQuotedItem) &&
Boolean(inheritedItem) &&
explicitQuotedItem !== inheritedItem;
const shouldInheritItem = Boolean(inheritedItem) &&
intent !== "inventory_aging_by_purchase_date" &&
!hasExplicitDifferentQuotedItem &&
(!effectiveCurrentItem ||
((0, addressFilterExtractor_1.isLowQualityInventoryItemAnchorValue)(effectiveCurrentItem) &&
!(0, addressFilterExtractor_1.isLowQualityInventoryItemAnchorValue)(inheritedItem ?? "")) ||
(effectiveCurrentItem &&
inheritedItem &&
(0, addressFilterExtractor_1.isInventoryItemAnchorDegradation)(inheritedItem, effectiveCurrentItem)));
if (shouldInheritItem && inheritedItem) {
merged.item = inheritedItem;
reasons.push("item_from_followup_context");
reasons.push(effectiveCurrentItem ? "item_replaced_from_followup_context" : "item_from_followup_context");
}
}
if (sameDateRequested) {

View File

@ -2,6 +2,7 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.normalizeAddressLlmSemanticHints = normalizeAddressLlmSemanticHints;
exports.applyAddressLlmSemanticHintsToExtraction = applyAddressLlmSemanticHintsToExtraction;
const addressFilterExtractor_1 = require("../addressFilterExtractor");
function toNonEmptyString(value) {
if (value === null || value === undefined) {
return null;
@ -66,6 +67,29 @@ function applyDateScopeHint(frame, dateScopeKind) {
frame.date_basis_hint = "implicit_current_snapshot";
}
}
function normalizeInventoryItemAnchorValue(value) {
return String(value ?? "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
}
function shouldApplyInventoryItemSemanticHint(currentItemValue, hintedItemValue) {
if (!hintedItemValue || (0, addressFilterExtractor_1.isLowQualityInventoryItemAnchorValue)(hintedItemValue)) {
return false;
}
if (!currentItemValue || (0, addressFilterExtractor_1.isLowQualityInventoryItemAnchorValue)(currentItemValue)) {
return true;
}
if ((0, addressFilterExtractor_1.isInventoryItemAnchorDegradation)(currentItemValue, hintedItemValue)) {
return false;
}
const currentNormalized = normalizeInventoryItemAnchorValue(currentItemValue);
const hintedNormalized = normalizeInventoryItemAnchorValue(hintedItemValue);
if (currentNormalized === hintedNormalized) {
return false;
}
return hintedNormalized.includes(currentNormalized);
}
function applyAddressLlmSemanticHintsToExtraction(extraction, semanticHintsInput) {
const semanticHints = normalizeAddressLlmSemanticHints(semanticHintsInput);
if (!semanticHints) {
@ -123,11 +147,17 @@ function applyAddressLlmSemanticHintsToExtraction(extraction, semanticHintsInput
semanticFrame.anchor_value = scopeTargetText;
}
if (semanticHints.scope_target_kind === "item" && scopeTargetText) {
extractedFilters.item = scopeTargetText;
pushWarning(warnings, "item_from_llm_semantics");
semanticFrame.scope_kind = "explicit_anchor";
semanticFrame.anchor_kind = "item";
semanticFrame.anchor_value = scopeTargetText;
const currentItemValue = toNonEmptyString(extractedFilters.item);
if (shouldApplyInventoryItemSemanticHint(currentItemValue, scopeTargetText)) {
extractedFilters.item = scopeTargetText;
pushWarning(warnings, "item_from_llm_semantics");
semanticFrame.scope_kind = "explicit_anchor";
semanticFrame.anchor_kind = "item";
semanticFrame.anchor_value = scopeTargetText;
}
else if (currentItemValue && currentItemValue !== scopeTargetText) {
pushWarning(warnings, "item_llm_semantics_ignored");
}
}
return {
...extraction,

View File

@ -57,6 +57,7 @@ const addressQueryService_1 = __importStar(require("./addressQueryService"));
const addressQueryClassifier_1 = __importStar(require("./addressQueryClassifier"));
const addressIntentResolver_1 = __importStar(require("./addressIntentResolver"));
const addressFilterExtractor_1 = __importStar(require("./addressFilterExtractor"));
const decomposeStage_1 = __importStar(require("./address_runtime/decomposeStage"));
const predecomposeContract_1 = __importStar(require("./address_runtime/predecomposeContract"));
const openaiResponsesClient_1 = __importStar(require("./openaiResponsesClient"));
const addressMcpClient_1 = __importStar(require("./addressMcpClient"));
@ -2767,8 +2768,15 @@ function hasShortInventoryObjectFollowupSignal(userMessage) {
if (minTokens > 8) {
return false;
}
const hasDirectSaleFollowupCue = (sample) => /(?:кому|каму|куда)(?:\s+\S+){0,4}\s+(?:продали|продано|продан(?:о|а|ы)?|реализовали|реализован(?:о|а|ы)?)|(?:продали|продано|реализовали|реализован(?:о|а|ы)?)(?:\s+\S+){0,4}\s+(?:кому|каму|куда)|(?:^|\s)(?:продано|продали|реализовано|реализовали)(?=$|[\s,.;:!?])/iu.test(sample);
return samples.some((sample) => /^(?:кто|когда|документы|сумма|поставщик|покупатель)(?:\?)?$/iu.test(sample) ||
/^(?:когда\s+(?:примерно\s+)?купили(?:\s+ее)?|каким\s+документом|покажи\s+документы|по\s+каким\s+документам|все\s+закупки|все\s+поступления|кому\s+(?:мы\s+)?продали|кто\s+купил|цепочка|путь\s+товара)(?:\?)?$/iu.test(sample));
hasDirectSaleFollowupCue(sample) ||
(0, decomposeStage_1.hasInventorySupplierFollowupCue)(sample) ||
(0, decomposeStage_1.hasInventoryPurchaseDocumentsFollowupCue)(sample) ||
(0, decomposeStage_1.hasInventoryPurchaseDateFollowupCue)(sample) ||
(0, decomposeStage_1.hasBareInventoryPurchaseDateFollowupCue)(sample) ||
(0, decomposeStage_1.hasInventorySaleFollowupCue)(sample) ||
(0, decomposeStage_1.hasInventoryPurchaseToSaleChainFollowupCue)(sample));
}
function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase());
@ -3408,6 +3416,13 @@ function resolveRequiredAnchorTypeForIntent(intent) {
if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") {
return "contract";
}
if (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") {
return "item";
}
return null;
}
function evaluateAddressAnchorQuality(message) {
@ -3425,7 +3440,9 @@ function evaluateAddressAnchorQuality(message) {
const extracted = (0, addressFilterExtractor_1.extractAddressFilters)(String(message ?? ""), intent);
const anchorValue = anchorType === "counterparty"
? toNonEmptyString(extracted?.extracted_filters?.counterparty)
: toNonEmptyString(extracted?.extracted_filters?.contract);
: anchorType === "contract"
? toNonEmptyString(extracted?.extracted_filters?.contract)
: toNonEmptyString(extracted?.extracted_filters?.item);
if (!anchorValue) {
return {
intent,
@ -3436,7 +3453,9 @@ function evaluateAddressAnchorQuality(message) {
}
const lowQuality = anchorType === "counterparty"
? isLowQualityPredecomposeCounterpartyAnchor(anchorValue)
: isLowQualityPredecomposeContractAnchor(anchorValue);
: anchorType === "contract"
? isLowQualityPredecomposeContractAnchor(anchorValue)
: (0, addressFilterExtractor_1.isLowQualityInventoryItemAnchorValue)(anchorValue);
return {
intent,
anchorType,
@ -3660,6 +3679,14 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate);
const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent;
const sourceSelectedObjectItemAnchorValue = toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(userMessage)) ??
toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(repairedSourceMessage || userMessage));
const candidateSemanticItemAnchorValue = (((sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType === "item") ||
Boolean(sourceSelectedObjectItemAnchorValue)) &&
candidateMeta?.semanticHints?.scope_target_kind === "item"
? toNonEmptyString(candidateMeta.semanticHints.scope_target_text)
: null);
const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType === "counterparty" &&
sourceAnchorQuality.quality >= 2 &&
@ -3684,6 +3711,25 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
const itemSemanticAnchorDegradedByCandidate = (sameIntentForAnchorSafety ||
Boolean(sourceSelectedObjectItemAnchorValue)) &&
Boolean(sourceSelectedObjectItemAnchorValue ?? sourceAnchorQuality.anchorValue) &&
Boolean(candidateSemanticItemAnchorValue) &&
(0, addressFilterExtractor_1.isInventoryItemAnchorDegradation)(sourceSelectedObjectItemAnchorValue ?? sourceAnchorQuality.anchorValue ?? "", candidateSemanticItemAnchorValue ?? "");
if (itemSemanticAnchorDegradedByCandidate) {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: false,
traceId: normalized?.trace_id ?? null,
llmCanonicalCandidateDetected: true,
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_anchor_degradation",
fallbackRuleHit: null,
sanitizedUserMessage,
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
const anchorDegradedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType &&
sourceAnchorQuality.quality >= 2 &&

View File

@ -0,0 +1,33 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.hasInventoryPurchaseStem = hasInventoryPurchaseStem;
exports.hasInventorySupplierCue = hasInventorySupplierCue;
exports.hasInventorySaleCue = hasInventorySaleCue;
function toText(value) {
return String(value ?? "");
}
function hasInventoryPurchaseStem(text) {
return /купл[а-яёa-z0-9_-]*/iu.test(toText(text));
}
function hasInventorySupplierCue(text) {
const value = toText(text);
if (/(?:кто\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+куплено|supplier|vendor|поставщик)/iu.test(value)) {
return true;
}
return hasInventoryPurchaseStem(value) && /(?:у\s+кого|от\s+кого|где)/iu.test(value);
}
function hasInventorySaleCue(text) {
const value = toText(text);
if (/(?:buyer|покупател)/iu.test(value)) {
return true;
}
if (/(?:куда\s+ушла\s+позиция|куда\s+ушел\s+товар|кто\s+купил)/iu.test(value)) {
return true;
}
const hasDirectionCue = /(?:кому|каму|куда)/iu.test(value);
const hasSaleVerb = /(?:продал(?:и|а|о|ы)?|продан(?:а|о|ы)?|продано|реализовал(?:и|а|о|ы)?|реализован(?:а|о|ы)?|реализовано)/iu.test(value);
if (hasDirectionCue && hasSaleVerb) {
return true;
}
return /(?:^|[\s,.;:!?])(продано|продали|продан(?:а|о|ы)?|реализовано|реализовали|реализован(?:а|о|ы)?)(?=$|[\s,.;:!?])/iu.test(value);
}

View File

@ -943,7 +943,7 @@ function usesRecipeDefaultLimit(intent: AddressIntent): boolean {
);
}
function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean {
export function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean {
const value = cleanupAnchorValue(rawValue)
.trim()
.toLowerCase()
@ -959,16 +959,47 @@ function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean {
return true;
}
const lowQualityTokens = new Set([
"в",
"во",
"на",
"по",
"у",
"от",
"из",
"для",
"и",
"или",
"это",
"этот",
"эту",
"его",
"ее",
"её",
"итог",
"итоге",
"итогу",
"сейчас",
"лежат",
"лежит",
"лежали",
"был",
"была",
"было",
"были",
"куплен",
"куплена",
"куплены",
"куплено",
"продан",
"продана",
"проданы",
"продано",
"продали",
"реализован",
"реализована",
"реализованы",
"реализовано",
"реализовали",
"документам",
"документами",
"документы",
@ -989,6 +1020,42 @@ function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean {
return meaningfulTokens.length === 0;
}
function normalizeInventoryItemAnchorForComparison(rawValue: string): string {
return cleanupAnchorValue(rawValue)
.trim()
.toLowerCase()
.replace(/С/g, "Рµ")
.replace(/\s+/g, " ");
}
function tokenizeInventoryItemAnchorForComparison(rawValue: string): string[] {
return normalizeInventoryItemAnchorForComparison(rawValue)
.split(/[^a-zа-я0-9]+/iu)
.map((token) => token.trim())
.filter(Boolean);
}
export function isInventoryItemAnchorDegradation(sourceValue: string, candidateValue: string): boolean {
const sourceNormalized = normalizeInventoryItemAnchorForComparison(sourceValue);
const candidateNormalized = normalizeInventoryItemAnchorForComparison(candidateValue);
if (!sourceNormalized || !candidateNormalized || sourceNormalized === candidateNormalized) {
return false;
}
if (isLowQualityInventoryItemAnchorValue(candidateNormalized)) {
return true;
}
if (sourceNormalized.includes(candidateNormalized) && candidateNormalized.length < sourceNormalized.length) {
return true;
}
const sourceTokens = tokenizeInventoryItemAnchorForComparison(sourceNormalized);
const candidateTokens = tokenizeInventoryItemAnchorForComparison(candidateNormalized);
return (
candidateTokens.length > 0 &&
candidateTokens.length < sourceTokens.length &&
candidateTokens.every((token) => sourceTokens.includes(token))
);
}
function cleanupInventoryItemAnchorValue(value: string): string {
return String(value ?? "")
.replace(/^['"«»`]+|['"«»`]+$/gu, "")
@ -1012,7 +1079,34 @@ function trimInventoryItemAnchorTail(rawValue: string): string {
return cleanupInventoryItemAnchorValue(value);
}
function extractSelectedObjectQuotedValue(text: string): string | undefined {
function extractQuotedAnchorValue(text: string): string | undefined {
const patterns = [/[«"]([^«»"\r\n]+)[»"]/u, /'([^'\r\n]+)'/u];
for (const pattern of patterns) {
const match = String(text ?? "").match(pattern);
const candidate = cleanupInventoryItemAnchorValue(String(match?.[1] ?? ""));
if (candidate) {
return candidate;
}
}
return undefined;
}
function extractUnicodeQuotedAnchorValue(text: string): string | undefined {
const patterns = [
/(?:\u00AB|\u0412\u00AB|")([^\u00AB\u00BB"\r\n]+?)(?:\u00BB|\u0412\u00BB|")/u,
/'([^'\r\n]+)'/u
];
for (const pattern of patterns) {
const match = String(text ?? "").match(pattern);
const candidate = cleanupInventoryItemAnchorValue(String(match?.[1] ?? ""));
if (candidate) {
return candidate;
}
}
return undefined;
}
export function extractSelectedObjectQuotedValue(text: string): string | undefined {
const patterns = [
/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*[«"]([^»"\r\n]+)[»"]/iu,
/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*:\s*[«"]([^»"\r\n]+)[»"]/iu
@ -1024,7 +1118,7 @@ function extractSelectedObjectQuotedValue(text: string): string | undefined {
return candidate;
}
}
return undefined;
return extractUnicodeQuotedAnchorValue(text) ?? extractQuotedAnchorValue(text);
}
function extractInventoryItemFromSelectedObject(text: string): string | undefined {
@ -1051,6 +1145,10 @@ function extractInventoryItemAnchor(text: string): string | undefined {
if (selectedObjectItem) {
return selectedObjectItem;
}
const quotedItem = extractUnicodeQuotedAnchorValue(text) ?? extractQuotedAnchorValue(text);
if (quotedItem && !isLowQualityInventoryItemAnchorValue(quotedItem)) {
return quotedItem;
}
const patterns = [
/(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s*[«"']([^«»"'?\r\n]+)[»"'](?=$|[\s,.;:!?])/iu,
/(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s+([^\r\n,.;:!?]+?)(?=\s+(?:на|по|у|от|из|для|и|когда|через|сейчас|еще|ещё|котор|которые|который|покупателю|поставщика|поставщику|за|в)\b|[:?]|$)/iu

View File

@ -1,4 +1,5 @@
import type { AddressIntentResolution } from "../types/addressQuery";
import { hasInventoryPurchaseStem, hasInventorySaleCue, hasInventorySupplierCue } from "./inventoryLifecycleCueHelpers";
const RECEIVABLES_STRONG = [
"кто должен нам",
@ -1616,12 +1617,7 @@ function hasSelectedObjectInventoryCue(text: string): boolean {
}
function hasSelectedObjectInventoryProvenanceSignal(text: string): boolean {
return (
hasSelectedObjectInventoryCue(text) &&
/(?:кто\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+куплено|supplier|vendor|поставщик)/iu.test(
text
)
);
return hasSelectedObjectInventoryCue(text) && hasInventorySupplierCue(text);
}
function hasSelectedObjectInventoryPurchaseDocumentsSignal(text: string): boolean {
@ -1634,24 +1630,16 @@ function hasSelectedObjectInventoryPurchaseDocumentsSignal(text: string): boolea
}
function hasSelectedObjectInventorySaleTraceSignal(text: string): boolean {
return (
hasSelectedObjectInventoryCue(text) &&
/(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|куда\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|куда\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale)/iu.test(
text
)
);
return hasSelectedObjectInventoryCue(text) && hasInventorySaleCue(text);
}
function hasInventoryProvenanceSignalV2(text: string): boolean {
const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:ок|ки)|склад)/iu.test(text);
const hasSupplierCue =
/(?:от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставщик|supplier|vendor)/iu.test(
text
);
const hasSupplierCue = hasInventorySupplierCue(text) || /кем\s+поставлен/iu.test(text);
const hasPurchaseCue =
/(?:куплен(?:ы|а|о)?|закупк|происхождени|откуда|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:ы|а)?|purchase\s+provenance|purchase\s+date)/iu.test(
text
);
) || hasInventoryPurchaseStem(text);
return hasItemCue && hasSupplierCue && hasPurchaseCue;
}

View File

@ -1,5 +1,7 @@
import type { AddressModeDetection } from "../types/addressQuery";
import { hasInventoryPurchaseStem, hasInventorySaleCue, hasInventorySupplierCue } from "./inventoryLifecycleCueHelpers";
const ADDRESS_ACTION_TOKENS = [
"show",
"list",
@ -286,8 +288,11 @@ function hasSelectedObjectInventoryFollowupSignal(text: string): boolean {
if (!/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)/iu.test(text)) {
return false;
}
return /(?:у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:поставил|продал)|кому\s+(?:продали|реализовали)|когда\s+(?:примерно\s+)?купили|по\s+каким\s+документам\s+.*купили)/iu.test(
text
return (
hasInventorySupplierCue(text) ||
hasInventorySaleCue(text) ||
/(?:кто\s+(?:поставил|продал)|по\s+каким\s+документам\s+.*купили)/iu.test(text) ||
(/\bкогда\b/iu.test(text) && hasInventoryPurchaseStem(text))
);
}

View File

@ -2008,8 +2008,6 @@ function shouldBoostAutoBroadenedLimit(intent: AddressIntent): boolean {
function shouldClearAsOfDateForHistoryRecovery(intent: AddressIntent): boolean {
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"
);
@ -2020,8 +2018,6 @@ function shouldDetachLifecycleExecutionFromSnapshotContext(
reasons: string[]
): boolean {
if (
intent !== "inventory_purchase_provenance_for_item" &&
intent !== "inventory_purchase_documents_for_item" &&
intent !== "inventory_sale_trace_for_item" &&
intent !== "inventory_purchase_to_sale_chain"
) {

View File

@ -47,6 +47,44 @@ __WHERE_CLAUSE__
Движения.Период __ORDER_DIRECTION__
`;
const INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
Товары.Ссылка.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"41.01" КАК СчетКт,
Товары.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация,
Товары.Количество КАК Количество
ИЗ
Документ.РеализацияТоваровУслуг.Товары КАК Товары
__WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Товары.Ссылка.Дата __ORDER_DIRECTION__
`;
const INVENTORY_PURCHASE_DOCUMENTS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
Товары.Ссылка.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор,
"41.01" КАК СчетДт,
"" КАК СчетКт,
Товары.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация,
Товары.Количество КАК Количество
ИЗ
Документ.ПоступлениеТоваровУслуг.Товары КАК Товары
__WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Товары.Ссылка.Дата __ORDER_DIRECTION__
`;
const PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период,
@ -972,17 +1010,6 @@ function toQueryStringLiteral(value: string): string {
return String(value ?? "").replace(/"/g, '""');
}
function buildOrganizationPresentationCondition(filters: AddressFilterSet, fieldPath: string): string | null {
const organization =
typeof filters.organization === "string" && filters.organization.trim().length > 0
? filters.organization.trim()
: "";
if (!organization) {
return null;
}
return `ПРЕДСТАВЛЕНИЕ(${fieldPath}) = "${toQueryStringLiteral(organization)}"`;
}
function buildWhereClause(filters: AddressFilterSet, fieldPath: string, extraConditions: string[] = []): string {
const periodFromExpr =
typeof filters.period_from === "string" && filters.period_from.trim().length > 0
@ -1154,15 +1181,56 @@ function buildInventoryMovementQuery(
: side === "kt"
? creditPredicate
: `(${debitPredicate} ИЛИ ${creditPredicate})`;
const organizationCondition = buildOrganizationPresentationCondition(filters, "Движения.Организация");
return INVENTORY_MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", [inventoryCondition]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
}
function buildInventoryItemReferenceCondition(filters: AddressFilterSet, fieldPaths: string[]): string | null {
const item = typeof filters.item === "string" ? filters.item.trim() : "";
if (!item) {
return null;
}
const escapedItem = toQueryStringLiteral(item);
const referenceSubquery =
`(ВЫБРАТЬ Номенклатура.Ссылка ИЗ Справочник.Номенклатура КАК Номенклатура ` +
`ГДЕ Номенклатура.Наименование = "${escapedItem}")`;
const clauses = fieldPaths
.map((fieldPath) => String(fieldPath ?? "").trim())
.filter((fieldPath) => fieldPath.length > 0)
.map((fieldPath) => `${fieldPath} В ${referenceSubquery}`);
if (clauses.length === 0) {
return null;
}
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function buildInventorySaleDocumentQuery(filters: AddressFilterSet, resolvedLimit: number): string {
const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]);
return INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace(
"__WHERE_CLAUSE__",
buildWhereClause(
filters,
"Движения.Период",
[inventoryCondition, organizationCondition].filter((item): item is string => Boolean(item))
"Товары.Ссылка.Дата",
['Товары.Ссылка.Проведен = ИСТИНА', itemCondition].filter((item): item is string => Boolean(item))
)
)
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
}
function buildInventoryPurchaseDocumentQuery(filters: AddressFilterSet, resolvedLimit: number): string {
const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]);
return INVENTORY_PURCHASE_DOCUMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace(
"__WHERE_CLAUSE__",
buildWhereClause(
filters,
"Товары.Ссылка.Дата",
['Товары.Ссылка.Проведен = ИСТИНА', itemCondition].filter((item): item is string => Boolean(item))
)
)
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
@ -1395,13 +1463,13 @@ export function buildAddressRecipePlan(
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: recipe.query_template === "inventory_purchase_provenance_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_documents_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_supplier_stock_overlap_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_sale_trace_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "kt")
? buildInventorySaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "either")
: recipe.query_template === "inventory_aging_by_purchase_date_profile"

View File

@ -4065,6 +4065,7 @@ export function composeFactualReply(
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен";
const boundedAsOfLabel = asOfDate ? formatDateRu(asOfDate) : null;
const purchaseDateActionFocus = hasInventoryPurchaseDateActionFocus(options.userMessage);
if (purchaseDateActionFocus) {
const firstPurchaseDate = inventoryTraceDateLabel(summary.firstPeriod);
@ -4074,7 +4075,9 @@ export function composeFactualReply(
? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.`
: summary.firstPeriod === summary.lastPeriod
? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.`
: `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`;
: boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`
: `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`;
const lines: string[] = [directAnswerLine];
if (purchaseRows.length > 0) {
lines.push("", "Подтверждение:");
@ -4090,8 +4093,13 @@ export function composeFactualReply(
if (summary.documents.length > 0) {
lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`);
}
if (summary.firstPeriod && asOfDate && summary.firstPeriod < asOfDate) {
lines.push(`- Дата вопроса по остатку: ${formatDateRu(asOfDate)}; дата закупки показана по подтвержденному закупочному следу.`);
if (boundedAsOfLabel) {
lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.counterparties.length > 1) {
lines.push(
`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`
);
}
}
return {
@ -4105,35 +4113,50 @@ export function composeFactualReply(
};
}
const directAnswerLine =
summary.counterparties.length === 1
? `Товар ${itemLabel} по доступным закупочным движениям связан с поставщиком: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? `По доступным закупочным движениям по товару ${itemLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По товару ${itemLabel} найден закупочный след, но поставщик не материализован отдельным полем в текущем exact-контуре.`;
purchaseRows.length <= 0
? boundedAsOfLabel
? `По позиции ${itemLabel} подтвержденные закупочные документы до ${boundedAsOfLabel} не найдены.`
: `По позиции ${itemLabel} подтвержденные закупочные документы не найдены.`
: summary.counterparties.length === 1
? boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден поставщик: ${summary.counterparties[0]}.`
: `По позиции ${itemLabel} подтвержден поставщик: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По позиции ${itemLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.`
: boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.`
: `По позиции ${itemLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.`;
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) {
lines.push(`- По доступным закупочным движениям найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`);
} else if (purchaseRows.length > 0) {
lines.push("- Закупочные документы найдены, но поставщик не материализован отдельным полем в текущем exact-контуре.");
if (purchaseRows.length > 0) {
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) {
lines.push(`- Поставщики в найденных документах: ${summary.counterparties.slice(0, 6).join("; ")}.`);
} else {
lines.push("- Закупочные документы найдены, но поставщик в них не выделен отдельным полем.");
}
if (boundedAsOfLabel) {
lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.counterparties.length > 1) {
lines.push(
`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`
);
}
} else if (boundedAsOfLabel) {
lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.documents.length > 0) {
lines.push("", "Опорные документы:", ...formatInventoryTraceRows(purchaseRows, 8));
}
if (purchaseRows.length > 0) {
lines.push(
"",
"Сервисно:",
"- Без партионности этот контур показывает документально наблюдаемый закупочный след, а не лот-level происхождение текущего остатка."
);
}
return {
responseType: purchaseRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY",
responseType: "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",

View File

@ -9,7 +9,13 @@
import { detectAddressQuestionMode } from "../addressQueryClassifier";
import { classifyAddressQueryShape } from "../addressQueryShapeClassifier";
import { resolveAddressIntent } from "../addressIntentResolver";
import { extractAddressFilters } from "../addressFilterExtractor";
import {
extractSelectedObjectQuotedValue,
extractAddressFilters,
isInventoryItemAnchorDegradation,
isLowQualityInventoryItemAnchorValue
} from "../addressFilterExtractor";
import { hasInventoryPurchaseStem, hasInventorySaleCue, hasInventorySupplierCue } from "../inventoryLifecycleCueHelpers";
import { applyAddressLlmSemanticHintsToExtraction } from "./semanticHintOverlay";
import type { AddressLlmSemanticHints } from "../../types/addressQuery";
@ -511,25 +517,24 @@ function hasSelectedObjectInventorySignal(text: string): boolean {
return /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(String(text ?? ""));
}
function hasInventorySupplierFollowupCue(text: string): boolean {
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+куплено|supplier|vendor|поставщик)/iu.test(
String(text ?? "")
);
export function hasInventorySupplierFollowupCue(text: string): boolean {
return hasInventorySupplierCue(String(text ?? ""));
}
function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean {
export function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean {
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: string): boolean {
export function hasInventoryPurchaseDateFollowupCue(text: string): boolean {
const value = String(text ?? "");
return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test(
String(text ?? "")
);
value
) || (/когда/iu.test(value) && hasInventoryPurchaseStem(value));
}
function hasBareInventoryPurchaseDateFollowupCue(text: string): boolean {
export function hasBareInventoryPurchaseDateFollowupCue(text: string): boolean {
const normalized = String(text ?? "").trim().toLowerCase();
if (!normalized) {
return false;
@ -537,13 +542,11 @@ function hasBareInventoryPurchaseDateFollowupCue(text: string): boolean {
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+(?:в\s+итоге\s+)?(?:мы\s+)?продали|куда\s+ушла\s+позиция|куда\s+ушел\s+товар|кто\s+купил|buyer|покупател)/iu.test(
String(text ?? "")
);
export function hasInventorySaleFollowupCue(text: string): boolean {
return hasInventorySaleCue(String(text ?? ""));
}
function hasInventoryPurchaseToSaleChainFollowupCue(text: string): boolean {
export 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 ?? "")
);
@ -777,13 +780,38 @@ function mergeFollowupFilters(
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") &&
!toNonEmptyString(merged.item)
intent === "inventory_aging_by_purchase_date")
) {
const inheritedItem = previousItem ?? previousAnchorItem;
if (inheritedItem && intent !== "inventory_aging_by_purchase_date") {
const explicitQuotedItem = toNonEmptyString(extractSelectedObjectQuotedValue(userMessage));
const currentItem = toNonEmptyString(merged.item);
const shouldAdoptExplicitQuotedItem =
Boolean(explicitQuotedItem) &&
(!currentItem ||
currentItem !== explicitQuotedItem ||
isInventoryItemAnchorDegradation(explicitQuotedItem ?? "", currentItem ?? ""));
if (explicitQuotedItem && shouldAdoptExplicitQuotedItem) {
merged.item = explicitQuotedItem;
reasons.push(currentItem ? "item_replaced_from_explicit_quote" : "item_from_explicit_quote");
}
const effectiveCurrentItem = toNonEmptyString(merged.item);
const hasExplicitDifferentQuotedItem =
Boolean(explicitQuotedItem) &&
Boolean(inheritedItem) &&
explicitQuotedItem !== inheritedItem;
const shouldInheritItem =
Boolean(inheritedItem) &&
intent !== "inventory_aging_by_purchase_date" &&
!hasExplicitDifferentQuotedItem &&
(!effectiveCurrentItem ||
(isLowQualityInventoryItemAnchorValue(effectiveCurrentItem) &&
!isLowQualityInventoryItemAnchorValue(inheritedItem ?? "")) ||
(effectiveCurrentItem &&
inheritedItem &&
isInventoryItemAnchorDegradation(inheritedItem, effectiveCurrentItem)));
if (shouldInheritItem && inheritedItem) {
merged.item = inheritedItem;
reasons.push("item_from_followup_context");
reasons.push(effectiveCurrentItem ? "item_replaced_from_followup_context" : "item_from_followup_context");
}
}
if (sameDateRequested) {

View File

@ -4,6 +4,7 @@ import type {
AddressLlmSemanticHints,
AddressSemanticFrame
} from "../../types/addressQuery";
import { isInventoryItemAnchorDegradation, isLowQualityInventoryItemAnchorValue } from "../addressFilterExtractor";
function toNonEmptyString(value: unknown): string | null {
if (value === null || value === undefined) {
@ -83,6 +84,31 @@ function applyDateScopeHint(frame: AddressSemanticFrame, dateScopeKind: AddressL
}
}
function normalizeInventoryItemAnchorValue(value: string): string {
return String(value ?? "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
}
function shouldApplyInventoryItemSemanticHint(currentItemValue: string | null, hintedItemValue: string): boolean {
if (!hintedItemValue || isLowQualityInventoryItemAnchorValue(hintedItemValue)) {
return false;
}
if (!currentItemValue || isLowQualityInventoryItemAnchorValue(currentItemValue)) {
return true;
}
if (isInventoryItemAnchorDegradation(currentItemValue, hintedItemValue)) {
return false;
}
const currentNormalized = normalizeInventoryItemAnchorValue(currentItemValue);
const hintedNormalized = normalizeInventoryItemAnchorValue(hintedItemValue);
if (currentNormalized === hintedNormalized) {
return false;
}
return hintedNormalized.includes(currentNormalized);
}
export function applyAddressLlmSemanticHintsToExtraction(
extraction: AddressFilterExtraction,
semanticHintsInput: unknown
@ -152,11 +178,16 @@ export function applyAddressLlmSemanticHintsToExtraction(
}
if (semanticHints.scope_target_kind === "item" && scopeTargetText) {
extractedFilters.item = scopeTargetText;
pushWarning(warnings, "item_from_llm_semantics");
semanticFrame.scope_kind = "explicit_anchor";
semanticFrame.anchor_kind = "item";
semanticFrame.anchor_value = scopeTargetText;
const currentItemValue = toNonEmptyString(extractedFilters.item);
if (shouldApplyInventoryItemSemanticHint(currentItemValue, scopeTargetText)) {
extractedFilters.item = scopeTargetText;
pushWarning(warnings, "item_from_llm_semantics");
semanticFrame.scope_kind = "explicit_anchor";
semanticFrame.anchor_kind = "item";
semanticFrame.anchor_value = scopeTargetText;
} else if (currentItemValue && currentItemValue !== scopeTargetText) {
pushWarning(warnings, "item_llm_semantics_ignored");
}
}
return {

View File

@ -11,6 +11,7 @@ import * as addressQueryService_1 from "./addressQueryService";
import * as addressQueryClassifier_1 from "./addressQueryClassifier";
import * as addressIntentResolver_1 from "./addressIntentResolver";
import * as addressFilterExtractor_1 from "./addressFilterExtractor";
import * as decomposeStage_1 from "./address_runtime/decomposeStage";
import * as predecomposeContract_1 from "./address_runtime/predecomposeContract";
import * as openaiResponsesClient_1 from "./openaiResponsesClient";
import * as addressMcpClient_1 from "./addressMcpClient";
@ -2725,8 +2726,15 @@ function hasShortInventoryObjectFollowupSignal(userMessage) {
if (minTokens > 8) {
return false;
}
const hasDirectSaleFollowupCue = (sample) => /(?:кому|каму|куда)(?:\s+\S+){0,4}\s+(?:продали|продано|продан(?:о|а|ы)?|реализовали|реализован(?:о|а|ы)?)|(?:продали|продано|реализовали|реализован(?:о|а|ы)?)(?:\s+\S+){0,4}\s+(?:кому|каму|куда)|(?:^|\s)(?:продано|продали|реализовано|реализовали)(?=$|[\s,.;:!?])/iu.test(sample);
return samples.some((sample) => /^(?:кто|когда|документы|сумма|поставщик|покупатель)(?:\?)?$/iu.test(sample) ||
/^(?:когда\s+(?:примерно\s+)?купили(?:\s+ее)?|каким\s+документом|покажи\s+документы|по\s+каким\s+документам|все\s+закупки|все\s+поступления|кому\s+(?:мы\s+)?продали|кто\s+купил|цепочка|путь\s+товара)(?:\?)?$/iu.test(sample));
hasDirectSaleFollowupCue(sample) ||
(0, decomposeStage_1.hasInventorySupplierFollowupCue)(sample) ||
(0, decomposeStage_1.hasInventoryPurchaseDocumentsFollowupCue)(sample) ||
(0, decomposeStage_1.hasInventoryPurchaseDateFollowupCue)(sample) ||
(0, decomposeStage_1.hasBareInventoryPurchaseDateFollowupCue)(sample) ||
(0, decomposeStage_1.hasInventorySaleFollowupCue)(sample) ||
(0, decomposeStage_1.hasInventoryPurchaseToSaleChainFollowupCue)(sample));
}
function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase());
@ -3366,6 +3374,13 @@ function resolveRequiredAnchorTypeForIntent(intent) {
if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") {
return "contract";
}
if (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") {
return "item";
}
return null;
}
function evaluateAddressAnchorQuality(message) {
@ -3383,7 +3398,9 @@ function evaluateAddressAnchorQuality(message) {
const extracted = (0, addressFilterExtractor_1.extractAddressFilters)(String(message ?? ""), intent);
const anchorValue = anchorType === "counterparty"
? toNonEmptyString(extracted?.extracted_filters?.counterparty)
: toNonEmptyString(extracted?.extracted_filters?.contract);
: anchorType === "contract"
? toNonEmptyString(extracted?.extracted_filters?.contract)
: toNonEmptyString(extracted?.extracted_filters?.item);
if (!anchorValue) {
return {
intent,
@ -3394,7 +3411,9 @@ function evaluateAddressAnchorQuality(message) {
}
const lowQuality = anchorType === "counterparty"
? isLowQualityPredecomposeCounterpartyAnchor(anchorValue)
: isLowQualityPredecomposeContractAnchor(anchorValue);
: anchorType === "contract"
? isLowQualityPredecomposeContractAnchor(anchorValue)
: (0, addressFilterExtractor_1.isLowQualityInventoryItemAnchorValue)(anchorValue);
return {
intent,
anchorType,
@ -3618,6 +3637,14 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate);
const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent;
const sourceSelectedObjectItemAnchorValue = toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(userMessage)) ??
toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(repairedSourceMessage || userMessage));
const candidateSemanticItemAnchorValue = (((sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType === "item") ||
Boolean(sourceSelectedObjectItemAnchorValue)) &&
candidateMeta?.semanticHints?.scope_target_kind === "item"
? toNonEmptyString(candidateMeta.semanticHints.scope_target_text)
: null);
const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType === "counterparty" &&
sourceAnchorQuality.quality >= 2 &&
@ -3642,6 +3669,25 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
const itemSemanticAnchorDegradedByCandidate = (sameIntentForAnchorSafety ||
Boolean(sourceSelectedObjectItemAnchorValue)) &&
Boolean(sourceSelectedObjectItemAnchorValue ?? sourceAnchorQuality.anchorValue) &&
Boolean(candidateSemanticItemAnchorValue) &&
(0, addressFilterExtractor_1.isInventoryItemAnchorDegradation)(sourceSelectedObjectItemAnchorValue ?? sourceAnchorQuality.anchorValue ?? "", candidateSemanticItemAnchorValue ?? "");
if (itemSemanticAnchorDegradedByCandidate) {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: false,
traceId: normalized?.trace_id ?? null,
llmCanonicalCandidateDetected: true,
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_anchor_degradation",
fallbackRuleHit: null,
sanitizedUserMessage,
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
const anchorDegradedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType &&
sourceAnchorQuality.quality >= 2 &&

View File

@ -0,0 +1,40 @@
function toText(value: string): string {
return String(value ?? "");
}
export function hasInventoryPurchaseStem(text: string): boolean {
return /купл[а-яёa-z0-9_-]*/iu.test(toText(text));
}
export function hasInventorySupplierCue(text: string): boolean {
const value = toText(text);
if (
/(?:кто\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+куплено|supplier|vendor|поставщик)/iu.test(
value
)
) {
return true;
}
return hasInventoryPurchaseStem(value) && /(?:у\s+кого|от\s+кого|где)/iu.test(value);
}
export function hasInventorySaleCue(text: string): boolean {
const value = toText(text);
if (/(?:buyer|покупател)/iu.test(value)) {
return true;
}
if (/(?:куда\s+ушла\s+позиция|куда\s+ушел\s+товар|кто\s+купил)/iu.test(value)) {
return true;
}
const hasDirectionCue = /(?:кому|каму|куда)/iu.test(value);
const hasSaleVerb =
/(?:продал(?:и|а|о|ы)?|продан(?:а|о|ы)?|продано|реализовал(?:и|а|о|ы)?|реализован(?:а|о|ы)?|реализовано)/iu.test(
value
);
if (hasDirectionCue && hasSaleVerb) {
return true;
}
return /(?:^|[\s,.;:!?])(продано|продали|продан(?:а|о|ы)?|реализовано|реализовали|реализован(?:а|о|ы)?)(?=$|[\s,.;:!?])/iu.test(
value
);
}

View File

@ -0,0 +1,103 @@
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();
vi.restoreAllMocks();
});
describe("inventory purchase provenance document route", () => {
it("uses document purchase route with native item resolution and snapshot upper bound for selected-object provenance", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 2,
matched_rows: 2,
raw_rows: [
{
Period: "2015-08-14T12:00:00Z",
Registrator: "Поступление товаров и услуг 00000000011 от 14.08.2015 12:00:00",
AccountDt: "41.01",
AccountKt: "",
Amount: 347680,
Quantity: 1,
Item: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
Counterparty: "Мебельная ф-ка №1",
Contract: "Договор поставки № 5 от 10.08.2015",
Organization: "ООО \\Альтернатива Плюс\\"
},
{
Period: "2015-09-10T12:00:00Z",
Registrator: "Поступление товаров и услуг 00000000017 от 10.09.2015 12:00:00",
AccountDt: "41.01",
AccountKt: "",
Amount: 347680,
Quantity: 1,
Item: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
Counterparty: "Авант мебель, ООО",
Contract: "Договор поставки № 8 от 01.09.2015",
Organization: "ООО \\Альтернатива Плюс\\"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": у кого куплено',
{
followupContext: {
previous_intent: "inventory_on_hand_as_of_date",
previous_filters: {
as_of_date: "2016-01-31",
period_from: "2016-01-01",
period_to: "2016-01-31",
organization: "ООО \\Альтернатива Плюс\\"
},
previous_anchor_type: "organization",
previous_anchor_value: "ООО \\Альтернатива Плюс\\"
}
}
);
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.selected_recipe).toBe("address_inventory_purchase_provenance_for_item_v1");
expect(result?.debug.extracted_filters?.item).toBe(
"Рабочая станция универсального специалиста (индивидуальное изготовление)"
);
expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-01-31");
expect(result?.debug.reasons ?? []).not.toContain("lifecycle_execution_detached_from_snapshot_date");
expect(result?.debug.reasons ?? []).not.toContain("as_of_date_cleared_for_history_recovery");
expect(String(result?.reply_text ?? "")).toContain("до 31.01.2016 подтвержден поставщик");
expect(String(result?.reply_text ?? "")).toContain("Авант мебель, ООО");
expect(String(result?.reply_text ?? "")).toContain("Для ответа учтены закупочные документы не позже 31.01.2016.");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
expect(query).toContain("Документ.ПоступлениеТоваровУслуг.Товары КАК Товары");
expect(query).toContain("Товары.Номенклатура В (ВЫБРАТЬ Номенклатура.Ссылка");
expect(query).toContain(
'Номенклатура.Наименование = "Рабочая станция универсального специалиста (индивидуальное изготовление)"'
);
expect(query).toContain("Товары.Ссылка.Дата <= ДАТАВРЕМЯ(2016, 1, 31, 23, 59, 59)");
expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент");
expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация");
expect(query).not.toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто");
});
});

View File

@ -0,0 +1,94 @@
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();
vi.restoreAllMocks();
});
describe("inventory sale trace document route", () => {
it("uses document sales route with native item resolution for selected-object buyer follow-up", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 2,
matched_rows: 2,
raw_rows: [
{
Period: "2015-02-25T12:00:00Z",
Registrator: "Реализация товаров и услуг 00000000012 от 25.02.2015 12:00:00",
AccountDt: "",
AccountKt: "41.01",
Amount: 12605435.66,
Quantity: 40,
Item: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
Counterparty: "Комитет государственных услуг г. Москвы",
Contract: "Гос.контракт № 42/15 от 20.02.2015г. Силино окна",
Organization: "ООО \\Альтернатива Плюс\\"
},
{
Period: "2015-02-09T12:00:14Z",
Registrator: "Реализация товаров и услуг 00000000004 от 09.02.2015 12:00:14",
AccountDt: "",
AccountKt: "41.01",
Amount: 16421320.17,
Quantity: 51,
Item: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
Counterparty: "Комитет государственных услуг г. Москвы",
Contract: "Гос.контракт № 17/15 от 02.02.2015г.",
Organization: "ООО \\Альтернатива Плюс\\"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": кому продали',
{
followupContext: {
previous_intent: "inventory_on_hand_as_of_date",
previous_filters: {
as_of_date: "2016-06-30",
period_from: "2016-06-01",
period_to: "2016-06-30",
organization: "ООО \\Альтернатива Плюс\\"
},
previous_anchor_type: "organization",
previous_anchor_value: "ООО \\Альтернатива Плюс\\"
}
}
);
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(String(result?.reply_text ?? "")).toContain("Комитет государственных услуг г. Москвы");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
expect(query).toContain("Документ.РеализацияТоваровУслуг.Товары КАК Товары");
expect(query).toContain("Товары.Номенклатура В (ВЫБРАТЬ Номенклатура.Ссылка");
expect(query).toContain('Номенклатура.Наименование = "Рабочая станция универсального специалиста (индивидуальное изготовление)"');
expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент");
expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация");
expect(query).not.toContain("2016-06-30");
expect(query).not.toContain("2016-06-01");
expect(query).not.toContain('ПРЕДСТАВЛЕНИЕ(Движения.Организация) = "ООО \\Альтернатива Плюс\\"');
});
});

View File

@ -0,0 +1,104 @@
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();
vi.restoreAllMocks();
});
describe("inventory sale trace selected-object regressions", () => {
const saleRow = {
Period: "2021-04-15T00:00:00Z",
Registrator: "Реализация товаров и услуг 00000000201 от 15.04.2021 0:00:00",
AccountDt: "",
AccountKt: "41.01",
Amount: 165.83,
Quantity: 1,
Item: "Кромка с клеем 33 дуб ниагара 137 м",
Counterparty: "ООО \\Покупатель\\",
Contract: "Договор реализации № 17 от 14.04.2021",
Organization: "ООО \\Альтернатива Плюс\\"
};
const followupContext = {
previous_intent: "inventory_purchase_provenance_for_item" as const,
previous_filters: {
as_of_date: "2021-03-31",
period_from: "2021-03-01",
period_to: "2021-03-31",
item: "Кромка с клеем 33 дуб ниагара 137 м",
organization: "ООО \\Альтернатива Плюс\\"
},
previous_anchor_type: "item" as const,
previous_anchor_value: "Кромка с клеем 33 дуб ниагара 137 м"
};
it("keeps the full selected item for explicit selected-object buyer wording from the live log", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [saleRow],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle('По выбранному объекту "Кромка с клеем 33 дуб ниагара 137 м": кому продали', {
followupContext
});
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.extracted_filters?.item).toBe("Кромка с клеем 33 дуб ниагара 137 м");
expect(String(result?.reply_text ?? "")).toContain("ООО \\Покупатель\\");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
expect(query).toContain("Документ.РеализацияТоваровУслуг.Товары КАК Товары");
expect(query).toContain('Номенклатура.Наименование = "Кромка с клеем 33 дуб ниагара 137 м"');
expect(query).not.toContain('Номенклатура.Наименование = "Кромка"');
});
it("keeps the full selected item for canonical selected-object buyer wording", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [saleRow],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
"Определить контрагента, которому была продана позиция «Кромка с клеем 33 дуб ниагара 137 м» по выбранному объекту",
{ followupContext }
);
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.extracted_filters?.item).toBe("Кромка с клеем 33 дуб ниагара 137 м");
expect(String(result?.reply_text ?? "")).toContain("ООО \\Покупатель\\");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
expect(query).toContain('Номенклатура.Наименование = "Кромка с клеем 33 дуб ниагара 137 м"');
expect(query).not.toContain('Номенклатура.Наименование = "Кромка"');
});
});

View File

@ -22,7 +22,7 @@ afterEach(() => {
});
describe("inventory selected-object follow-up", () => {
it("inherits dated stock window for selected-object provenance and then auto-broadens history", async () => {
it("inherits dated stock upper bound for selected-object provenance and then auto-broadens history", async () => {
executeAddressMcpQueryMock
.mockResolvedValueOnce({
fetched_rows: 1,
@ -108,9 +108,10 @@ describe("inventory selected-object follow-up", () => {
expect(result?.debug.reasons).toContain("period_window_auto_broadened_to_available_data");
expect(result?.debug.limitations).toContain("period_window_auto_broadened_to_available_data");
const replyLines = String(result?.reply_text ?? "").split("\n");
expect(replyLines[0]).toContain("Товар Кромка с клеем 33 альмандин 137 м");
expect(replyLines[0]).toContain("По позиции Кромка с клеем 33 альмандин 137 м");
expect(replyLines[0]).toContain("до 31.03.2021 подтвержден поставщик");
expect(replyLines[0]).toContain("Торговый дом \\Союз МСК\\");
expect(replyLines[1]).toContain("По окну 2021-03-01..2021-03-31 строк не найдено");
expect(String(result?.reply_text ?? "")).toContain("Для ответа учтены закупочные документы не позже 31.03.2021.");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(2);
});
@ -397,6 +398,56 @@ describe("inventory selected-object follow-up", () => {
expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\");
});
it("handles selected-object typo wording 'где куплего' as provenance follow-up", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2016-05-20T00:00:00Z",
Registrator: "Поступление товаров и услуг 00000000009 от 20.05.2016 0:00:00",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 695360,
SubcontoDt1: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
SubcontoDt3: "Основной склад",
SubcontoKt1: "ООО \\Производство мебели\\",
SubcontoKt2: "Договор поставки № 5 от 16.05.2016",
Organization: "ООО \\Альтернатива Плюс\\"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где куплего',
{
followupContext: {
previous_intent: "inventory_on_hand_as_of_date",
previous_filters: {
as_of_date: "2016-05-31",
period_from: "2016-05-01",
period_to: "2016-05-31",
warehouse: "Основной склад",
organization: "ООО \\Альтернатива Плюс\\"
},
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.selected_recipe).toBe("address_inventory_purchase_provenance_for_item_v1");
expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-05-31");
expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\");
});
it("handles selected-object purchase-doc slang 'по каким документам это купили' as exact purchase-doc follow-up", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
@ -538,6 +589,52 @@ describe("inventory selected-object follow-up", () => {
expect(String(result?.reply_text ?? "")).toContain("Документы выбытия");
});
it("promotes short buyer follow-up after provenance answer into sale trace", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2020-04-15T00:00:00Z",
Registrator: "Реализация товаров и услуг 00000000119 от 15.04.2020 0:00:00",
AccountDt: "90.02",
AccountKt: "41.01",
Amount: 199,
SubcontoKt1: "Кромка с клеем 33 альмандин 137 м",
SubcontoDt1: "ООО \\Покупатель\\",
SubcontoDt2: "Договор реализации № 17 от 14.04.2020",
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: "Кромка с клеем 33 альмандин 137 м",
organization: "ООО \\Альтернатива Плюс\\"
},
previous_anchor_type: "item",
previous_anchor_value: "Кромка с клеем 33 альмандин 137 м"
}
});
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("Кромка с клеем 33 альмандин 137 м");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31");
expect(String(result?.reply_text ?? "")).toContain("ООО \\Покупатель\\");
});
it("detaches snapshot date from execution query during sale-trace history recovery", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
@ -623,7 +720,54 @@ describe("inventory selected-object follow-up", () => {
expect(String(result?.reply_text ?? "")).not.toContain("совпадений не нашлось");
});
it("detaches snapshot date from execution query for selected-object provenance after dated stock slice", async () => {
it.skip("keeps the full selected item when sale trace is asked in canonical wording after provenance", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2021-04-15T00:00:00Z",
Registrator: "Реализация товаров Рё услуг 00000000201 РѕС 15.04.2021 0:00:00",
AccountDt: "90.02",
AccountKt: "41.01",
Amount: 165.83,
SubcontoKt1: "Кромка с клеем 33 дуб ниагара 137 м",
SubcontoDt1: "ООО \\Покупатель\\",
SubcontoDt2: "Договор реализации в„– 17 РѕС 14.04.2021",
Organization: "ООО \\Альтернатива Плюс\\"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
"Определить контрагента, которому была продана позиция «Кромка с клеем 33 дуб ниагара 137 м» по выбранному объекту",
{
followupContext: {
previous_intent: "inventory_purchase_provenance_for_item",
previous_filters: {
as_of_date: "2021-03-31",
period_from: "2021-03-01",
period_to: "2021-03-31",
item: "Кромка с клеем 33 дуб ниагара 137 м",
organization: "ООО \\Альтернатива Плюс\\"
},
previous_anchor_type: "item",
previous_anchor_value: "Кромка с клеем 33 дуб ниагара 137 м"
}
}
);
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.extracted_filters?.item).toBe("Кромка с клеем 33 дуб ниагара 137 м");
expect(String(result?.reply_text ?? "")).toContain("ООО \\Покупатель\\");
});
it("keeps snapshot date as an upper bound for selected-object provenance after dated stock slice", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
@ -667,16 +811,16 @@ describe("inventory selected-object follow-up", () => {
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31");
expect(result?.debug.extracted_filters?.period_from).toBeUndefined();
expect(result?.debug.extracted_filters?.period_to).toBeUndefined();
expect(result?.debug.reasons).toContain("lifecycle_execution_detached_from_snapshot_date");
expect(result?.debug.reasons).toContain("as_of_date_cleared_for_history_recovery");
expect(result?.debug.limitations).toContain("lifecycle_execution_detached_from_snapshot_date");
expect(result?.debug.limitations).toContain("as_of_date_cleared_for_history_recovery");
expect(result?.debug.reasons ?? []).not.toContain("lifecycle_execution_detached_from_snapshot_date");
expect(result?.debug.reasons ?? []).not.toContain("as_of_date_cleared_for_history_recovery");
expect(result?.debug.limitations ?? []).not.toContain("lifecycle_execution_detached_from_snapshot_date");
expect(result?.debug.limitations ?? []).not.toContain("as_of_date_cleared_for_history_recovery");
expect(String(result?.reply_text ?? "")).toContain("ООО \\Гамма-мебель\\");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
expect(query).not.toContain("2020-03-31");
expect(query).not.toContain("2020-03-01");
expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Движения.Организация) КАК Организация");
expect(query).toContain('ПРЕДСТАВЛЕНИЕ(Движения.Организация) = "ООО \\Альтернатива Плюс\\"');
expect(query).toContain("Документ.ПоступлениеТоваровУслуг.Товары КАК Товары");
expect(query).toContain("Товары.Номенклатура В (ВЫБРАТЬ Номенклатура.Ссылка");
expect(query).toContain("Товары.Ссылка.Дата <= ДАТАВРЕМЯ(2020, 3, 31, 23, 59, 59)");
expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация");
});
});

View File

@ -7,6 +7,7 @@ import { AddressQueryService } from "../src/services/addressQueryService";
import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog";
import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage";
import { composeFactualReply } from "../src/services/address_runtime/composeStage";
import { applyAddressLlmSemanticHintsToExtraction } from "../src/services/address_runtime/semanticHintOverlay";
describe("address query shape classifier", () => {
it("classifies explain question as deep-shape", () => {
@ -236,6 +237,17 @@ describe("address query shape classifier", () => {
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
});
it("keeps selected-object typo wording 'где куплего' in inventory provenance intent", () => {
const mode = detectAddressQuestionMode(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где куплего'
);
const result = resolveAddressIntent(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где куплего'
);
expect(mode.mode).toBe("address_query");
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
});
it("keeps selected-object purchase-doc slang with 'по каким документам это купили' in purchase-doc intent", () => {
const mode = detectAddressQuestionMode(
'По выбранному объекту "Столешница 600*3050*26 дуб ниагара": по каким документам это купили'
@ -4115,6 +4127,55 @@ describe("address decompose stage follow-up carryover", () => {
expect(result?.filters.extracted_filters.as_of_date).toBe("2020-03-31");
});
it("promotes canonical buyer wording 'кому был реализован товар в итоге' into inventory sale trace", () => {
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: "Кромка с клеем 33 альмандин 137 м"
},
previous_anchor_type: "item",
previous_anchor_value: "Кромка с клеем 33 альмандин 137 м"
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_sale_trace_for_item");
expect(result?.filters.extracted_filters.item).toBe("Кромка с клеем 33 альмандин 137 м");
expect(result?.filters.extracted_filters.as_of_date).toBe("2020-03-31");
});
it("ignores degraded llm semantic item hint when extraction already has the full inventory item", () => {
const result = applyAddressLlmSemanticHintsToExtraction(
{
extracted_filters: {
sort: "period_desc",
item: "Кромка с клеем 33 дуб ниагара 137 м"
},
missing_required_filters: [],
warnings: [],
semantic_frame: {
scope_kind: "selected_object_scope",
anchor_kind: "selected_object",
anchor_value: null,
date_scope_kind: "none",
date_basis_hint: null,
self_scope_detected: false,
selected_object_scope_detected: true
}
},
{
scope_target_kind: "item",
scope_target_text: "Кромка",
date_scope_kind: "missing",
self_scope_detected: false,
selected_object_scope_detected: true
}
);
expect(result.extracted_filters.item).toBe("Кромка с клеем 33 дуб ниагара 137 м");
expect(result.warnings).toContain("item_llm_semantics_ignored");
});
it("keeps slang all-customers-all-time wording in address lane via resolved intent fallback", () => {
const result = runAddressDecomposeStage("выведи всех заков за все время", null);
expect(result).not.toBeNull();

View File

@ -131,7 +131,7 @@ describe("assistant address follow-up carryover", () => {
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(["factual", "factual_with_explanation"]).toContain(second.reply_type);
expect(second.debug?.detected_mode).toBe("address_query");
expect(second.debug?.detected_intent).toBe("list_documents_by_counterparty");
expect(second.debug?.extracted_filters?.counterparty).toBe("свк");
@ -203,7 +203,7 @@ describe("assistant address follow-up carryover", () => {
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(["factual", "factual_with_explanation"]).toContain(second.reply_type);
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
@ -268,7 +268,7 @@ describe("assistant address follow-up carryover", () => {
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(["factual", "factual_with_explanation"]).toContain(second.reply_type);
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
@ -373,6 +373,118 @@ describe("assistant address follow-up carryover", () => {
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats short buyer follow-up as continuation of the active provenance object", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "каму в итоге продано";
const provenanceResult = {
handled: true,
reply_text:
"По позиции Рабочая станция универсального специалиста (индивидуальное изготовление) до 31.01.2016 однозначный поставщик не подтвержден.",
reply_type: "factual",
response_type: "FACTUAL_SUMMARY",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_purchase_provenance_for_item",
detected_intent_confidence: "medium",
extracted_filters: {
item: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
warehouse: "Основной склад",
organization: "ООО \\Альтернатива Плюс\\",
as_of_date: "2016-01-31"
},
missing_required_filters: [],
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
anchor_type: "item",
anchor_value_raw: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
anchor_value_resolved: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
reasons: ["address_action_detected", "address_entity_detected"],
dialog_continuation_contract_v2: {
decision: "continue_previous"
}
}
} as any;
const saleTraceResult = {
handled: true,
reply_text:
"По позиции Рабочая станция универсального специалиста (индивидуальное изготовление) подтвержден покупатель: Комитет государственных услуг г. Москвы.",
reply_type: "factual",
response_type: "FACTUAL_LIST",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_sale_trace_for_item",
detected_intent_confidence: "medium",
extracted_filters: {
item: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
organization: "ООО \\Альтернатива Плюс\\",
as_of_date: "2016-01-31"
},
selected_recipe: "address_inventory_sale_trace_for_item_v1",
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
} 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 saleTraceResult;
}
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-buyer-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-inventory-provenance-buyer-seed",
session_id: sessionId,
role: "assistant",
text: provenanceResult.reply_text,
reply_type: provenanceResult.reply_type,
created_at: "2026-04-15T12:24:22.251Z",
trace_id: "address-provenance-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(["factual", "factual_with_explanation"]).toContain(second.reply_type);
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(
"Рабочая станция универсального специалиста (индивидуальное изготовление)"
);
expect(calls[0].options?.followupContext?.previous_filters?.warehouse).toBe("Основной склад");
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2016-01-31");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("keeps historical stock date window for selected-object supplier wording 'у кого куплено'", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const rootMessage = 'какие у нас остатки на складе на июнь 2020';

View File

@ -502,7 +502,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
]).toContain(response.debug?.llm_decomposition_reason);
});
it("prefers raw selected-object sale follow-up when llm rewrite drifts into generic open-items intent", async () => {
it("accepts exact selected-object sale rewrite when llm candidate stays on the same item", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
@ -668,9 +668,114 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": кому мы это продали в итоге'
"Определить контрагента, которому была реализована позиция «Рабочая станция универсального специалиста (индивидуальное изготовление)» по выбранному объекту"
);
expect(response.debug?.llm_decomposition_reason).toBe("followup_raw_message_preferred_over_llm_rewrite");
expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_applied");
});
it("keeps a canonical selected-object sale rewrite executable even when llm semantic hints collapse the item noun", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const sourceMessage = 'По выбранному объекту "Кромка с клеем 33 дуб ниагара 137 м": кому продали';
const candidateMessage =
"Определить контрагента, которому была продана позиция «Кромка с клеем 33 дуб ниагара 137 м» по выбранному объекту";
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-item-anchor-degradation",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: sourceMessage,
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: [
{
fragment_id: "F1",
raw_fragment_text: sourceMessage,
normalized_fragment_text: candidateMessage,
semantic_hints: {
scope_target_kind: "item",
scope_target_text: "Кромка",
date_scope_kind: "missing",
self_scope_detected: false,
selected_object_scope_detected: true
},
domain_relevance: "in_scope",
business_scope: "company_specific_accounting",
entity_hints: [],
account_hints: [],
document_hints: [],
register_hints: [],
time_scope: {
type: "missing",
value: null,
confidence: "low"
},
flags: {
has_multi_entity_scope: false,
asks_for_chain_explanation: false,
asks_for_ranking_or_top: false,
asks_for_period_summary: false,
asks_for_rule_check: false,
asks_for_anomaly_scan: false,
asks_for_exact_object_trace: true,
asks_for_evidence: false,
mentions_period_close_context: false
},
candidate_labels: ["simple_factual"],
confidence: "medium",
execution_readiness: "executable",
clarification_reason: null,
soft_assumption_used: [],
route_status: "routed",
no_route_reason: null
}
],
discarded_fragments: [],
global_notes: {
needs_clarification: false,
clarification_reason: null
}
},
raw_model_output: null,
validation: { passed: true, errors: [] },
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
latency_ms: 10,
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
request_count_for_case: 1
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const response = await service.handleMessage({
session_id: `asst-predecompose-item-anchor-degradation-${Date.now()}`,
user_message: sourceMessage,
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe(candidateMessage);
expect(String(response.debug?.llm_decomposition_effective_message ?? "")).toBe(candidateMessage);
});
it("does not treat service verb as counterparty anchor when llm rewrites noisy bank phrase", async () => {