АРЧ - Склад: сохранять полный 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 }; return (mod && mod.__esModule) ? mod : { "default": mod };
}; };
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.isLowQualityInventoryItemAnchorValue = isLowQualityInventoryItemAnchorValue;
exports.isInventoryItemAnchorDegradation = isInventoryItemAnchorDegradation;
exports.extractSelectedObjectQuotedValue = extractSelectedObjectQuotedValue;
exports.extractAddressFilters = extractAddressFilters; exports.extractAddressFilters = extractAddressFilters;
const iconv_lite_1 = __importDefault(require("iconv-lite")); const iconv_lite_1 = __importDefault(require("iconv-lite"));
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i; const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
@ -836,16 +839,47 @@ function isLowQualityInventoryItemAnchorValue(rawValue) {
return true; return true;
} }
const lowQualityTokens = new Set([ const lowQualityTokens = new Set([
"в",
"во",
"на",
"по",
"у",
"от",
"из",
"для",
"и",
"или",
"это",
"этот",
"эту",
"его",
"ее",
"её",
"итог",
"итоге",
"итогу",
"сейчас", "сейчас",
"лежат", "лежат",
"лежит", "лежит",
"лежали", "лежали",
"был",
"была",
"было",
"были",
"куплен", "куплен",
"куплена", "куплена",
"куплены", "куплены",
"куплено",
"продан", "продан",
"продана", "продана",
"проданы", "проданы",
"продано",
"продали",
"реализован",
"реализована",
"реализованы",
"реализовано",
"реализовали",
"документам", "документам",
"документами", "документами",
"документы", "документы",
@ -865,6 +899,37 @@ function isLowQualityInventoryItemAnchorValue(rawValue) {
.filter((token) => !lowQualityTokens.has(token)); .filter((token) => !lowQualityTokens.has(token));
return meaningfulTokens.length === 0; 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) { function cleanupInventoryItemAnchorValue(value) {
return String(value ?? "") return String(value ?? "")
.replace(/^['"«»“”„`]+|['"«»“”„`]+$/gu, "") .replace(/^['"«»“”„`]+|['"«»“”„`]+$/gu, "")
@ -886,6 +951,31 @@ function trimInventoryItemAnchorTail(rawValue) {
} }
return cleanupInventoryItemAnchorValue(value); 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) { function extractSelectedObjectQuotedValue(text) {
const patterns = [ const patterns = [
/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*[«"]([^»"\r\n]+)[»"]/iu, /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*[«"]([^»"\r\n]+)[»"]/iu,
@ -898,7 +988,7 @@ function extractSelectedObjectQuotedValue(text) {
return candidate; return candidate;
} }
} }
return undefined; return extractUnicodeQuotedAnchorValue(text) ?? extractQuotedAnchorValue(text);
} }
function extractInventoryItemFromSelectedObject(text) { function extractInventoryItemFromSelectedObject(text) {
const selectedObject = extractSelectedObjectQuotedValue(text); const selectedObject = extractSelectedObjectQuotedValue(text);
@ -923,6 +1013,10 @@ function extractInventoryItemAnchor(text) {
if (selectedObjectItem) { if (selectedObjectItem) {
return selectedObjectItem; return selectedObjectItem;
} }
const quotedItem = extractUnicodeQuotedAnchorValue(text) ?? extractQuotedAnchorValue(text);
if (quotedItem && !isLowQualityInventoryItemAnchorValue(quotedItem)) {
return quotedItem;
}
const patterns = [ const patterns = [
/(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s*[«"']([^«»"'?\r\n]+)[»"'](?=$|[\s,.;:!?])/iu, /(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s*[«"']([^«»"'?\r\n]+)[»"'](?=$|[\s,.;:!?])/iu,
/(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s+([^\r\n,.;:!?]+?)(?=\s+(?:на|по|у|от|из|для|и|когда|через|сейчас|еще|ещё|котор|которые|который|покупателю|поставщика|поставщику|за|в)\b|[:?]|$)/iu /(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s+([^\r\n,.;:!?]+?)(?=\s+(?:на|по|у|от|из|для|и|когда|через|сейчас|еще|ещё|котор|которые|который|покупателю|поставщика|поставщику|за|в)\b|[:?]|$)/iu

View File

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

View File

@ -1,6 +1,7 @@
"use strict"; "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.detectAddressQuestionMode = detectAddressQuestionMode; exports.detectAddressQuestionMode = detectAddressQuestionMode;
const inventoryLifecycleCueHelpers_1 = require("./inventoryLifecycleCueHelpers");
const ADDRESS_ACTION_TOKENS = [ const ADDRESS_ACTION_TOKENS = [
"show", "show",
"list", "list",
@ -277,7 +278,10 @@ function hasSelectedObjectInventoryFollowupSignal(text) {
if (!/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)/iu.test(text)) { if (!/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)/iu.test(text)) {
return false; 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) { function hasDocsOrBankSignal(text) {
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|поступлен|списан|транзак|transactions?|bank\s+ops|bank\s+operations?)/iu.test(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"); intent === "inventory_aging_by_purchase_date");
} }
function shouldClearAsOfDateForHistoryRecovery(intent) { function shouldClearAsOfDateForHistoryRecovery(intent) {
return (intent === "inventory_purchase_provenance_for_item" || return (intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain"); intent === "inventory_purchase_to_sale_chain");
} }
function shouldDetachLifecycleExecutionFromSnapshotContext(intent, reasons) { function shouldDetachLifecycleExecutionFromSnapshotContext(intent, reasons) {
if (intent !== "inventory_purchase_provenance_for_item" && if (intent !== "inventory_sale_trace_for_item" &&
intent !== "inventory_purchase_documents_for_item" &&
intent !== "inventory_sale_trace_for_item" &&
intent !== "inventory_purchase_to_sale_chain") { intent !== "inventory_purchase_to_sale_chain") {
return false; return false;
} }

View File

@ -42,6 +42,42 @@ __WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО УПОРЯДОЧИТЬ ПО
Движения.Период __ORDER_DIRECTION__ Движения.Период __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 = ` const PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период, __AS_OF_EXPR__ КАК Период,
@ -938,15 +974,6 @@ function toDateTimeExpr(isoDate, endOfDay) {
function toQueryStringLiteral(value) { function toQueryStringLiteral(value) {
return String(value ?? "").replace(/"/g, '""'); 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 = []) { function buildWhereClause(filters, fieldPath, extraConditions = []) {
const periodFromExpr = typeof filters.period_from === "string" && filters.period_from.trim().length > 0 const periodFromExpr = typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, false) ? toDateTimeExpr(filters.period_from, false)
@ -1087,10 +1114,40 @@ function buildInventoryMovementQuery(filters, resolvedLimit, side) {
: side === "kt" : side === "kt"
? creditPredicate ? creditPredicate
: `(${debitPredicate} ИЛИ ${creditPredicate})`; : `(${debitPredicate} ИЛИ ${creditPredicate})`;
const organizationCondition = buildOrganizationPresentationCondition(filters, "Движения.Организация");
return INVENTORY_MOVEMENTS_QUERY_TEMPLATE return INVENTORY_MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit)) .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)); .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
} }
function shouldBoostLimitForAllTimeCounterparty(filters) { function shouldBoostLimitForAllTimeCounterparty(filters) {
@ -1269,13 +1326,13 @@ function buildAddressRecipePlan(recipe, filters) {
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})() })()
: recipe.query_template === "inventory_purchase_provenance_profile" : recipe.query_template === "inventory_purchase_provenance_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt") ? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_documents_profile" : recipe.query_template === "inventory_purchase_documents_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt") ? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_supplier_stock_overlap_profile" : recipe.query_template === "inventory_supplier_stock_overlap_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt") ? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_sale_trace_profile" : recipe.query_template === "inventory_sale_trace_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "kt") ? buildInventorySaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_to_sale_chain_profile" : recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "either") ? buildInventoryMovementQuery(filters, resolvedLimit, "either")
: recipe.query_template === "inventory_aging_by_purchase_date_profile" : 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 purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows); const summary = summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен"; const itemLabel = summary.item ?? "товар не определен";
const boundedAsOfLabel = asOfDate ? formatDateRu(asOfDate) : null;
const purchaseDateActionFocus = hasInventoryPurchaseDateActionFocus(options.userMessage); const purchaseDateActionFocus = hasInventoryPurchaseDateActionFocus(options.userMessage);
if (purchaseDateActionFocus) { if (purchaseDateActionFocus) {
const firstPurchaseDate = inventoryTraceDateLabel(summary.firstPeriod); const firstPurchaseDate = inventoryTraceDateLabel(summary.firstPeriod);
@ -3148,7 +3149,9 @@ function composeFactualReply(intent, rows, options = {}) {
? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.` ? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.`
: summary.firstPeriod === summary.lastPeriod : summary.firstPeriod === summary.lastPeriod
? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.` ? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.`
: `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`; : boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`
: `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`;
const lines = [directAnswerLine]; const lines = [directAnswerLine];
if (purchaseRows.length > 0) { if (purchaseRows.length > 0) {
lines.push("", "Подтверждение:"); lines.push("", "Подтверждение:");
@ -3165,8 +3168,11 @@ function composeFactualReply(intent, rows, options = {}) {
if (summary.documents.length > 0) { if (summary.documents.length > 0) {
lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`); lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`);
} }
if (summary.firstPeriod && asOfDate && summary.firstPeriod < asOfDate) { if (boundedAsOfLabel) {
lines.push(`- Дата вопроса по остатку: ${formatDateRu(asOfDate)}; дата закупки показана по подтвержденному закупочному следу.`); lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.counterparties.length > 1) {
lines.push(`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`);
} }
} }
return { return {
@ -3179,33 +3185,51 @@ function composeFactualReply(intent, rows, options = {}) {
} }
}; };
} }
const directAnswerLine = summary.counterparties.length === 1 const directAnswerLine = purchaseRows.length <= 0
? `Товар ${itemLabel} по доступным закупочным движениям связан с поставщиком: ${summary.counterparties[0]}.` ? boundedAsOfLabel
: summary.counterparties.length > 1 ? `По позиции ${itemLabel} подтвержденные закупочные документы до ${boundedAsOfLabel} не найдены.`
? `По доступным закупочным движениям по товару ${itemLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.` : `По позиции ${itemLabel} подтвержденные закупочные документы не найдены.`
: `По товару ${itemLabel} найден закупочный след, но поставщик не материализован отдельным полем в текущем exact-контуре.`; : 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, "", "Подтверждение:"]; const lines = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`); if (purchaseRows.length > 0) {
lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`); lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`);
lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`); lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`);
lines.push(`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`); lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`);
if (summary.counterparties.length === 1) { lines.push(`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`);
lines.push(`- По доступным закупочным движениям товар связан с поставщиком: ${summary.counterparties[0]}.`); 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) { else if (boundedAsOfLabel) {
lines.push(`- По доступным закупочным движениям найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`); lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`);
}
else if (purchaseRows.length > 0) {
lines.push("- Закупочные документы найдены, но поставщик не материализован отдельным полем в текущем exact-контуре.");
} }
if (summary.documents.length > 0) { if (summary.documents.length > 0) {
lines.push("", "Опорные документы:", ...formatInventoryTraceRows(purchaseRows, 8)); lines.push("", "Опорные документы:", ...formatInventoryTraceRows(purchaseRows, 8));
} }
if (purchaseRows.length > 0) {
lines.push("", "Сервисно:", "- Без партионности этот контур показывает документально наблюдаемый закупочный след, а не лот-level происхождение текущего остатка.");
}
return { return {
responseType: purchaseRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY", responseType: "FACTUAL_SUMMARY",
text: joinLines(lines), text: joinLines(lines),
semantics: { semantics: {
result_mode: "confirmed_balance", result_mode: "confirmed_balance",

View File

@ -1,11 +1,18 @@
"use strict"; "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); 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.hasAddressFollowupContextSignal = hasAddressFollowupContextSignal;
exports.runAddressDecomposeStage = runAddressDecomposeStage; exports.runAddressDecomposeStage = runAddressDecomposeStage;
const addressQueryClassifier_1 = require("../addressQueryClassifier"); const addressQueryClassifier_1 = require("../addressQueryClassifier");
const addressQueryShapeClassifier_1 = require("../addressQueryShapeClassifier"); const addressQueryShapeClassifier_1 = require("../addressQueryShapeClassifier");
const addressIntentResolver_1 = require("../addressIntentResolver"); const addressIntentResolver_1 = require("../addressIntentResolver");
const addressFilterExtractor_1 = require("../addressFilterExtractor"); const addressFilterExtractor_1 = require("../addressFilterExtractor");
const inventoryLifecycleCueHelpers_1 = require("../inventoryLifecycleCueHelpers");
const semanticHintOverlay_1 = require("./semanticHintOverlay"); const semanticHintOverlay_1 = require("./semanticHintOverlay");
function hasExplicitPeriodWindow(filters) { function hasExplicitPeriodWindow(filters) {
return ((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) || 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 ?? "")); return /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(String(text ?? ""));
} }
function hasInventorySupplierFollowupCue(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) { 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 ?? "")); 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) { 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) { function hasBareInventoryPurchaseDateFollowupCue(text) {
const normalized = String(text ?? "").trim().toLowerCase(); 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; return /(?:^|\s)(?:когда|а\s+когда|ну\s+когда)(?=$|[\s,.;:!?])/iu.test(normalized) && normalized.split(/\s+/).filter(Boolean).length <= 3;
} }
function hasInventorySaleFollowupCue(text) { 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) { 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 ?? "")); 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_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" || intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" || intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") && intent === "inventory_aging_by_purchase_date")) {
!toNonEmptyString(merged.item)) {
const inheritedItem = previousItem ?? previousAnchorItem; 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; merged.item = inheritedItem;
reasons.push("item_from_followup_context"); reasons.push(effectiveCurrentItem ? "item_replaced_from_followup_context" : "item_from_followup_context");
} }
} }
if (sameDateRequested) { if (sameDateRequested) {

View File

@ -2,6 +2,7 @@
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.normalizeAddressLlmSemanticHints = normalizeAddressLlmSemanticHints; exports.normalizeAddressLlmSemanticHints = normalizeAddressLlmSemanticHints;
exports.applyAddressLlmSemanticHintsToExtraction = applyAddressLlmSemanticHintsToExtraction; exports.applyAddressLlmSemanticHintsToExtraction = applyAddressLlmSemanticHintsToExtraction;
const addressFilterExtractor_1 = require("../addressFilterExtractor");
function toNonEmptyString(value) { function toNonEmptyString(value) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return null; return null;
@ -66,6 +67,29 @@ function applyDateScopeHint(frame, dateScopeKind) {
frame.date_basis_hint = "implicit_current_snapshot"; 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) { function applyAddressLlmSemanticHintsToExtraction(extraction, semanticHintsInput) {
const semanticHints = normalizeAddressLlmSemanticHints(semanticHintsInput); const semanticHints = normalizeAddressLlmSemanticHints(semanticHintsInput);
if (!semanticHints) { if (!semanticHints) {
@ -123,11 +147,17 @@ function applyAddressLlmSemanticHintsToExtraction(extraction, semanticHintsInput
semanticFrame.anchor_value = scopeTargetText; semanticFrame.anchor_value = scopeTargetText;
} }
if (semanticHints.scope_target_kind === "item" && scopeTargetText) { if (semanticHints.scope_target_kind === "item" && scopeTargetText) {
extractedFilters.item = scopeTargetText; const currentItemValue = toNonEmptyString(extractedFilters.item);
pushWarning(warnings, "item_from_llm_semantics"); if (shouldApplyInventoryItemSemanticHint(currentItemValue, scopeTargetText)) {
semanticFrame.scope_kind = "explicit_anchor"; extractedFilters.item = scopeTargetText;
semanticFrame.anchor_kind = "item"; pushWarning(warnings, "item_from_llm_semantics");
semanticFrame.anchor_value = scopeTargetText; 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 { return {
...extraction, ...extraction,

View File

@ -57,6 +57,7 @@ const addressQueryService_1 = __importStar(require("./addressQueryService"));
const addressQueryClassifier_1 = __importStar(require("./addressQueryClassifier")); const addressQueryClassifier_1 = __importStar(require("./addressQueryClassifier"));
const addressIntentResolver_1 = __importStar(require("./addressIntentResolver")); const addressIntentResolver_1 = __importStar(require("./addressIntentResolver"));
const addressFilterExtractor_1 = __importStar(require("./addressFilterExtractor")); const addressFilterExtractor_1 = __importStar(require("./addressFilterExtractor"));
const decomposeStage_1 = __importStar(require("./address_runtime/decomposeStage"));
const predecomposeContract_1 = __importStar(require("./address_runtime/predecomposeContract")); const predecomposeContract_1 = __importStar(require("./address_runtime/predecomposeContract"));
const openaiResponsesClient_1 = __importStar(require("./openaiResponsesClient")); const openaiResponsesClient_1 = __importStar(require("./openaiResponsesClient"));
const addressMcpClient_1 = __importStar(require("./addressMcpClient")); const addressMcpClient_1 = __importStar(require("./addressMcpClient"));
@ -2767,8 +2768,15 @@ function hasShortInventoryObjectFollowupSignal(userMessage) {
if (minTokens > 8) { if (minTokens > 8) {
return false; 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) || 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) { function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase()); const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase());
@ -3408,6 +3416,13 @@ function resolveRequiredAnchorTypeForIntent(intent) {
if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") { if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") {
return "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; return null;
} }
function evaluateAddressAnchorQuality(message) { function evaluateAddressAnchorQuality(message) {
@ -3425,7 +3440,9 @@ function evaluateAddressAnchorQuality(message) {
const extracted = (0, addressFilterExtractor_1.extractAddressFilters)(String(message ?? ""), intent); const extracted = (0, addressFilterExtractor_1.extractAddressFilters)(String(message ?? ""), intent);
const anchorValue = anchorType === "counterparty" const anchorValue = anchorType === "counterparty"
? toNonEmptyString(extracted?.extracted_filters?.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) { if (!anchorValue) {
return { return {
intent, intent,
@ -3436,7 +3453,9 @@ function evaluateAddressAnchorQuality(message) {
} }
const lowQuality = anchorType === "counterparty" const lowQuality = anchorType === "counterparty"
? isLowQualityPredecomposeCounterpartyAnchor(anchorValue) ? isLowQualityPredecomposeCounterpartyAnchor(anchorValue)
: isLowQualityPredecomposeContractAnchor(anchorValue); : anchorType === "contract"
? isLowQualityPredecomposeContractAnchor(anchorValue)
: (0, addressFilterExtractor_1.isLowQualityInventoryItemAnchorValue)(anchorValue);
return { return {
intent, intent,
anchorType, anchorType,
@ -3660,6 +3679,14 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage); const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate); const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate);
const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent; 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 && const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType === "counterparty" && sourceAnchorQuality.anchorType === "counterparty" &&
sourceAnchorQuality.quality >= 2 && sourceAnchorQuality.quality >= 2 &&
@ -3684,6 +3711,25 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
semanticHints: candidateMeta?.semanticHints ?? null semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage); }, 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 && const anchorDegradedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType && sourceAnchorQuality.anchorType &&
sourceAnchorQuality.quality >= 2 && 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) const value = cleanupAnchorValue(rawValue)
.trim() .trim()
.toLowerCase() .toLowerCase()
@ -959,16 +959,47 @@ function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean {
return true; return true;
} }
const lowQualityTokens = new Set([ const lowQualityTokens = new Set([
"в",
"во",
"на",
"по",
"у",
"от",
"из",
"для",
"и",
"или",
"это",
"этот",
"эту",
"его",
"ее",
"её",
"итог",
"итоге",
"итогу",
"сейчас", "сейчас",
"лежат", "лежат",
"лежит", "лежит",
"лежали", "лежали",
"был",
"была",
"было",
"были",
"куплен", "куплен",
"куплена", "куплена",
"куплены", "куплены",
"куплено",
"продан", "продан",
"продана", "продана",
"проданы", "проданы",
"продано",
"продали",
"реализован",
"реализована",
"реализованы",
"реализовано",
"реализовали",
"документам", "документам",
"документами", "документами",
"документы", "документы",
@ -989,6 +1020,42 @@ function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean {
return meaningfulTokens.length === 0; 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 { function cleanupInventoryItemAnchorValue(value: string): string {
return String(value ?? "") return String(value ?? "")
.replace(/^['"«»`]+|['"«»`]+$/gu, "") .replace(/^['"«»`]+|['"«»`]+$/gu, "")
@ -1012,7 +1079,34 @@ function trimInventoryItemAnchorTail(rawValue: string): string {
return cleanupInventoryItemAnchorValue(value); 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 = [ const patterns = [
/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*[«"]([^»"\r\n]+)[»"]/iu, /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*[«"]([^»"\r\n]+)[»"]/iu,
/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*:\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 candidate;
} }
} }
return undefined; return extractUnicodeQuotedAnchorValue(text) ?? extractQuotedAnchorValue(text);
} }
function extractInventoryItemFromSelectedObject(text: string): string | undefined { function extractInventoryItemFromSelectedObject(text: string): string | undefined {
@ -1051,6 +1145,10 @@ function extractInventoryItemAnchor(text: string): string | undefined {
if (selectedObjectItem) { if (selectedObjectItem) {
return selectedObjectItem; return selectedObjectItem;
} }
const quotedItem = extractUnicodeQuotedAnchorValue(text) ?? extractQuotedAnchorValue(text);
if (quotedItem && !isLowQualityInventoryItemAnchorValue(quotedItem)) {
return quotedItem;
}
const patterns = [ const patterns = [
/(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s*[«"']([^«»"'?\r\n]+)[»"'](?=$|[\s,.;:!?])/iu, /(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s*[«"']([^«»"'?\r\n]+)[»"'](?=$|[\s,.;:!?])/iu,
/(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s+([^\r\n,.;:!?]+?)(?=\s+(?:на|по|у|от|из|для|и|когда|через|сейчас|еще|ещё|котор|которые|который|покупателю|поставщика|поставщику|за|в)\b|[:?]|$)/iu /(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s+([^\r\n,.;:!?]+?)(?=\s+(?:на|по|у|от|из|для|и|когда|через|сейчас|еще|ещё|котор|которые|который|покупателю|поставщика|поставщику|за|в)\b|[:?]|$)/iu

View File

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

View File

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

View File

@ -47,6 +47,44 @@ __WHERE_CLAUSE__
Движения.Период __ORDER_DIRECTION__ Движения.Период __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 = ` const PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период, __AS_OF_EXPR__ КАК Период,
@ -972,17 +1010,6 @@ function toQueryStringLiteral(value: string): string {
return String(value ?? "").replace(/"/g, '""'); 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 { function buildWhereClause(filters: AddressFilterSet, fieldPath: string, extraConditions: string[] = []): string {
const periodFromExpr = const periodFromExpr =
typeof filters.period_from === "string" && filters.period_from.trim().length > 0 typeof filters.period_from === "string" && filters.period_from.trim().length > 0
@ -1154,15 +1181,56 @@ function buildInventoryMovementQuery(
: side === "kt" : side === "kt"
? creditPredicate ? creditPredicate
: `(${debitPredicate} ИЛИ ${creditPredicate})`; : `(${debitPredicate} ИЛИ ${creditPredicate})`;
const organizationCondition = buildOrganizationPresentationCondition(filters, "Движения.Организация");
return INVENTORY_MOVEMENTS_QUERY_TEMPLATE 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("__LIMIT__", String(resolvedLimit))
.replace( .replace(
"__WHERE_CLAUSE__", "__WHERE_CLAUSE__",
buildWhereClause( buildWhereClause(
filters, 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)); .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
@ -1395,13 +1463,13 @@ export function buildAddressRecipePlan(
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})() })()
: recipe.query_template === "inventory_purchase_provenance_profile" : recipe.query_template === "inventory_purchase_provenance_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt") ? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_documents_profile" : recipe.query_template === "inventory_purchase_documents_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt") ? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_supplier_stock_overlap_profile" : recipe.query_template === "inventory_supplier_stock_overlap_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt") ? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_sale_trace_profile" : recipe.query_template === "inventory_sale_trace_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "kt") ? buildInventorySaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_to_sale_chain_profile" : recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "either") ? buildInventoryMovementQuery(filters, resolvedLimit, "either")
: recipe.query_template === "inventory_aging_by_purchase_date_profile" : 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 purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows); const summary = summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен"; const itemLabel = summary.item ?? "товар не определен";
const boundedAsOfLabel = asOfDate ? formatDateRu(asOfDate) : null;
const purchaseDateActionFocus = hasInventoryPurchaseDateActionFocus(options.userMessage); const purchaseDateActionFocus = hasInventoryPurchaseDateActionFocus(options.userMessage);
if (purchaseDateActionFocus) { if (purchaseDateActionFocus) {
const firstPurchaseDate = inventoryTraceDateLabel(summary.firstPeriod); const firstPurchaseDate = inventoryTraceDateLabel(summary.firstPeriod);
@ -4074,7 +4075,9 @@ export function composeFactualReply(
? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.` ? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.`
: summary.firstPeriod === summary.lastPeriod : summary.firstPeriod === summary.lastPeriod
? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.` ? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.`
: `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`; : boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`
: `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`;
const lines: string[] = [directAnswerLine]; const lines: string[] = [directAnswerLine];
if (purchaseRows.length > 0) { if (purchaseRows.length > 0) {
lines.push("", "Подтверждение:"); lines.push("", "Подтверждение:");
@ -4090,8 +4093,13 @@ export function composeFactualReply(
if (summary.documents.length > 0) { if (summary.documents.length > 0) {
lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`); lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`);
} }
if (summary.firstPeriod && asOfDate && summary.firstPeriod < asOfDate) { if (boundedAsOfLabel) {
lines.push(`- Дата вопроса по остатку: ${formatDateRu(asOfDate)}; дата закупки показана по подтвержденному закупочному следу.`); lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.counterparties.length > 1) {
lines.push(
`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`
);
} }
} }
return { return {
@ -4105,35 +4113,50 @@ export function composeFactualReply(
}; };
} }
const directAnswerLine = const directAnswerLine =
summary.counterparties.length === 1 purchaseRows.length <= 0
? `Товар ${itemLabel} по доступным закупочным движениям связан с поставщиком: ${summary.counterparties[0]}.` ? boundedAsOfLabel
: summary.counterparties.length > 1 ? `По позиции ${itemLabel} подтвержденные закупочные документы до ${boundedAsOfLabel} не найдены.`
? `По доступным закупочным движениям по товару ${itemLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.` : `По позиции ${itemLabel} подтвержденные закупочные документы не найдены.`
: `По товару ${itemLabel} найден закупочный след, но поставщик не материализован отдельным полем в текущем exact-контуре.`; : 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, "", "Подтверждение:"]; const lines: string[] = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`); if (purchaseRows.length > 0) {
lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`); lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`);
lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`); lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`);
lines.push(`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`); lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`);
if (summary.counterparties.length === 1) { lines.push(`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`);
lines.push(`- По доступным закупочным движениям товар связан с поставщиком: ${summary.counterparties[0]}.`); if (summary.counterparties.length === 1) {
} else if (summary.counterparties.length > 1) { lines.push(`- Поставщик в найденных документах: ${summary.counterparties[0]}.`);
lines.push(`- По доступным закупочным движениям найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`); } else if (summary.counterparties.length > 1) {
} else if (purchaseRows.length > 0) { lines.push(`- Поставщики в найденных документах: ${summary.counterparties.slice(0, 6).join("; ")}.`);
lines.push("- Закупочные документы найдены, но поставщик не материализован отдельным полем в текущем exact-контуре."); } 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) { if (summary.documents.length > 0) {
lines.push("", "Опорные документы:", ...formatInventoryTraceRows(purchaseRows, 8)); lines.push("", "Опорные документы:", ...formatInventoryTraceRows(purchaseRows, 8));
} }
if (purchaseRows.length > 0) {
lines.push(
"",
"Сервисно:",
"- Без партионности этот контур показывает документально наблюдаемый закупочный след, а не лот-level происхождение текущего остатка."
);
}
return { return {
responseType: purchaseRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY", responseType: "FACTUAL_SUMMARY",
text: joinLines(lines), text: joinLines(lines),
semantics: { semantics: {
result_mode: "confirmed_balance", result_mode: "confirmed_balance",

View File

@ -9,7 +9,13 @@
import { detectAddressQuestionMode } from "../addressQueryClassifier"; import { detectAddressQuestionMode } from "../addressQueryClassifier";
import { classifyAddressQueryShape } from "../addressQueryShapeClassifier"; import { classifyAddressQueryShape } from "../addressQueryShapeClassifier";
import { resolveAddressIntent } from "../addressIntentResolver"; 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 { applyAddressLlmSemanticHintsToExtraction } from "./semanticHintOverlay";
import type { AddressLlmSemanticHints } from "../../types/addressQuery"; 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 ?? "")); return /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(String(text ?? ""));
} }
function hasInventorySupplierFollowupCue(text: string): boolean { export 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( return hasInventorySupplierCue(String(text ?? ""));
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( 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 ?? "") 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( 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(); const normalized = String(text ?? "").trim().toLowerCase();
if (!normalized) { if (!normalized) {
return false; 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; return /(?:^|\s)(?:когда|а\s+когда|ну\s+когда)(?=$|[\s,.;:!?])/iu.test(normalized) && normalized.split(/\s+/).filter(Boolean).length <= 3;
} }
function hasInventorySaleFollowupCue(text: string): boolean { export 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( return hasInventorySaleCue(String(text ?? ""));
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( return /(?:через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale)/iu.test(
String(text ?? "") String(text ?? "")
); );
@ -777,13 +780,38 @@ function mergeFollowupFilters(
intent === "inventory_purchase_documents_for_item" || intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" || intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" || intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") && intent === "inventory_aging_by_purchase_date")
!toNonEmptyString(merged.item)
) { ) {
const inheritedItem = previousItem ?? previousAnchorItem; 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; merged.item = inheritedItem;
reasons.push("item_from_followup_context"); reasons.push(effectiveCurrentItem ? "item_replaced_from_followup_context" : "item_from_followup_context");
} }
} }
if (sameDateRequested) { if (sameDateRequested) {

View File

@ -4,6 +4,7 @@ import type {
AddressLlmSemanticHints, AddressLlmSemanticHints,
AddressSemanticFrame AddressSemanticFrame
} from "../../types/addressQuery"; } from "../../types/addressQuery";
import { isInventoryItemAnchorDegradation, isLowQualityInventoryItemAnchorValue } from "../addressFilterExtractor";
function toNonEmptyString(value: unknown): string | null { function toNonEmptyString(value: unknown): string | null {
if (value === null || value === undefined) { 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( export function applyAddressLlmSemanticHintsToExtraction(
extraction: AddressFilterExtraction, extraction: AddressFilterExtraction,
semanticHintsInput: unknown semanticHintsInput: unknown
@ -152,11 +178,16 @@ export function applyAddressLlmSemanticHintsToExtraction(
} }
if (semanticHints.scope_target_kind === "item" && scopeTargetText) { if (semanticHints.scope_target_kind === "item" && scopeTargetText) {
extractedFilters.item = scopeTargetText; const currentItemValue = toNonEmptyString(extractedFilters.item);
pushWarning(warnings, "item_from_llm_semantics"); if (shouldApplyInventoryItemSemanticHint(currentItemValue, scopeTargetText)) {
semanticFrame.scope_kind = "explicit_anchor"; extractedFilters.item = scopeTargetText;
semanticFrame.anchor_kind = "item"; pushWarning(warnings, "item_from_llm_semantics");
semanticFrame.anchor_value = scopeTargetText; 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 { return {

View File

@ -11,6 +11,7 @@ import * as addressQueryService_1 from "./addressQueryService";
import * as addressQueryClassifier_1 from "./addressQueryClassifier"; import * as addressQueryClassifier_1 from "./addressQueryClassifier";
import * as addressIntentResolver_1 from "./addressIntentResolver"; import * as addressIntentResolver_1 from "./addressIntentResolver";
import * as addressFilterExtractor_1 from "./addressFilterExtractor"; import * as addressFilterExtractor_1 from "./addressFilterExtractor";
import * as decomposeStage_1 from "./address_runtime/decomposeStage";
import * as predecomposeContract_1 from "./address_runtime/predecomposeContract"; import * as predecomposeContract_1 from "./address_runtime/predecomposeContract";
import * as openaiResponsesClient_1 from "./openaiResponsesClient"; import * as openaiResponsesClient_1 from "./openaiResponsesClient";
import * as addressMcpClient_1 from "./addressMcpClient"; import * as addressMcpClient_1 from "./addressMcpClient";
@ -2725,8 +2726,15 @@ function hasShortInventoryObjectFollowupSignal(userMessage) {
if (minTokens > 8) { if (minTokens > 8) {
return false; 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) || 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) { function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase()); const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase());
@ -3366,6 +3374,13 @@ function resolveRequiredAnchorTypeForIntent(intent) {
if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") { if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") {
return "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; return null;
} }
function evaluateAddressAnchorQuality(message) { function evaluateAddressAnchorQuality(message) {
@ -3383,7 +3398,9 @@ function evaluateAddressAnchorQuality(message) {
const extracted = (0, addressFilterExtractor_1.extractAddressFilters)(String(message ?? ""), intent); const extracted = (0, addressFilterExtractor_1.extractAddressFilters)(String(message ?? ""), intent);
const anchorValue = anchorType === "counterparty" const anchorValue = anchorType === "counterparty"
? toNonEmptyString(extracted?.extracted_filters?.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) { if (!anchorValue) {
return { return {
intent, intent,
@ -3394,7 +3411,9 @@ function evaluateAddressAnchorQuality(message) {
} }
const lowQuality = anchorType === "counterparty" const lowQuality = anchorType === "counterparty"
? isLowQualityPredecomposeCounterpartyAnchor(anchorValue) ? isLowQualityPredecomposeCounterpartyAnchor(anchorValue)
: isLowQualityPredecomposeContractAnchor(anchorValue); : anchorType === "contract"
? isLowQualityPredecomposeContractAnchor(anchorValue)
: (0, addressFilterExtractor_1.isLowQualityInventoryItemAnchorValue)(anchorValue);
return { return {
intent, intent,
anchorType, anchorType,
@ -3618,6 +3637,14 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage); const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate); const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate);
const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent; 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 && const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType === "counterparty" && sourceAnchorQuality.anchorType === "counterparty" &&
sourceAnchorQuality.quality >= 2 && sourceAnchorQuality.quality >= 2 &&
@ -3642,6 +3669,25 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
semanticHints: candidateMeta?.semanticHints ?? null semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage); }, 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 && const anchorDegradedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType && sourceAnchorQuality.anchorType &&
sourceAnchorQuality.quality >= 2 && 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", () => { 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 executeAddressMcpQueryMock
.mockResolvedValueOnce({ .mockResolvedValueOnce({
fetched_rows: 1, 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.reasons).toContain("period_window_auto_broadened_to_available_data");
expect(result?.debug.limitations).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"); 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[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); expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(2);
}); });
@ -397,6 +398,56 @@ describe("inventory selected-object follow-up", () => {
expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\"); 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 () => { it("handles selected-object purchase-doc slang 'по каким документам это купили' as exact purchase-doc follow-up", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({ executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1, fetched_rows: 1,
@ -538,6 +589,52 @@ describe("inventory selected-object follow-up", () => {
expect(String(result?.reply_text ?? "")).toContain("Документы выбытия"); 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 () => { it("detaches snapshot date from execution query during sale-trace history recovery", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({ executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1, fetched_rows: 1,
@ -623,7 +720,54 @@ describe("inventory selected-object follow-up", () => {
expect(String(result?.reply_text ?? "")).not.toContain("совпадений не нашлось"); 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({ executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1, fetched_rows: 1,
matched_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?.as_of_date).toBe("2020-03-31");
expect(result?.debug.extracted_filters?.period_from).toBeUndefined(); expect(result?.debug.extracted_filters?.period_from).toBeUndefined();
expect(result?.debug.extracted_filters?.period_to).toBeUndefined(); expect(result?.debug.extracted_filters?.period_to).toBeUndefined();
expect(result?.debug.reasons).toContain("lifecycle_execution_detached_from_snapshot_date"); expect(result?.debug.reasons ?? []).not.toContain("lifecycle_execution_detached_from_snapshot_date");
expect(result?.debug.reasons).toContain("as_of_date_cleared_for_history_recovery"); expect(result?.debug.reasons ?? []).not.toContain("as_of_date_cleared_for_history_recovery");
expect(result?.debug.limitations).toContain("lifecycle_execution_detached_from_snapshot_date"); expect(result?.debug.limitations ?? []).not.toContain("lifecycle_execution_detached_from_snapshot_date");
expect(result?.debug.limitations).toContain("as_of_date_cleared_for_history_recovery"); expect(result?.debug.limitations ?? []).not.toContain("as_of_date_cleared_for_history_recovery");
expect(String(result?.reply_text ?? "")).toContain("ООО \\Гамма-мебель\\"); expect(String(result?.reply_text ?? "")).toContain("ООО \\Гамма-мебель\\");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? ""); const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
expect(query).not.toContain("2020-03-31"); expect(query).toContain("Документ.ПоступлениеТоваровУслуг.Товары КАК Товары");
expect(query).not.toContain("2020-03-01"); expect(query).toContain("Товары.Номенклатура В (ВЫБРАТЬ Номенклатура.Ссылка");
expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Движения.Организация) КАК Организация"); expect(query).toContain("Товары.Ссылка.Дата <= ДАТАВРЕМЯ(2020, 3, 31, 23, 59, 59)");
expect(query).toContain('ПРЕДСТАВЛЕНИЕ(Движения.Организация) = "ООО \\Альтернатива Плюс\\"'); expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация");
}); });
}); });

View File

@ -7,6 +7,7 @@ import { AddressQueryService } from "../src/services/addressQueryService";
import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog"; import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog";
import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage"; import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage";
import { composeFactualReply } from "../src/services/address_runtime/composeStage"; import { composeFactualReply } from "../src/services/address_runtime/composeStage";
import { applyAddressLlmSemanticHintsToExtraction } from "../src/services/address_runtime/semanticHintOverlay";
describe("address query shape classifier", () => { describe("address query shape classifier", () => {
it("classifies explain question as deep-shape", () => { 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"); 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", () => { it("keeps selected-object purchase-doc slang with 'по каким документам это купили' in purchase-doc intent", () => {
const mode = detectAddressQuestionMode( const mode = detectAddressQuestionMode(
'По выбранному объекту "Столешница 600*3050*26 дуб ниагара": по каким документам это купили' 'По выбранному объекту "Столешница 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"); 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", () => { it("keeps slang all-customers-all-time wording in address lane via resolved intent fallback", () => {
const result = runAddressDecomposeStage("выведи всех заков за все время", null); const result = runAddressDecomposeStage("выведи всех заков за все время", null);
expect(result).not.toBeNull(); expect(result).not.toBeNull();

View File

@ -131,7 +131,7 @@ describe("assistant address follow-up carryover", () => {
} as any); } as any);
expect(second.ok).toBe(true); 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_mode).toBe("address_query");
expect(second.debug?.detected_intent).toBe("list_documents_by_counterparty"); expect(second.debug?.detected_intent).toBe("list_documents_by_counterparty");
expect(second.debug?.extracted_filters?.counterparty).toBe("свк"); expect(second.debug?.extracted_filters?.counterparty).toBe("свк");
@ -203,7 +203,7 @@ describe("assistant address follow-up carryover", () => {
} as any); } as any);
expect(second.ok).toBe(true); 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).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage); expect(calls[1].message).toBe(followupMessage);
expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty"); expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
@ -268,7 +268,7 @@ describe("assistant address follow-up carryover", () => {
useMock: true useMock: true
} as any); } as any);
expect(second.ok).toBe(true); 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).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage); expect(calls[1].message).toBe(followupMessage);
@ -373,6 +373,118 @@ describe("assistant address follow-up carryover", () => {
expect(normalizerService.normalize).not.toHaveBeenCalled(); 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 () => { it("keeps historical stock date window for selected-object supplier wording 'у кого куплено'", async () => {
const calls: Array<{ message: string; options?: any }> = []; const calls: Array<{ message: string; options?: any }> = [];
const rootMessage = 'какие у нас остатки на складе на июнь 2020'; const rootMessage = 'какие у нас остатки на складе на июнь 2020';

View File

@ -502,7 +502,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
]).toContain(response.debug?.llm_decomposition_reason); ]).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 calls: Array<{ message: string }> = [];
const addressQueryService = { const addressQueryService = {
tryHandle: vi.fn(async (message: string) => { 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(response.reply_type).toBe("factual");
expect(calls).toHaveLength(2); expect(calls).toHaveLength(2);
expect(calls[1].message).toBe( 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 () => { it("does not treat service verb as counterparty anchor when llm rewrites noisy bank phrase", async () => {