diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 73831e4..7854b2c 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -3,6 +3,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); +exports.isLowQualityInventoryItemAnchorValue = isLowQualityInventoryItemAnchorValue; +exports.isInventoryItemAnchorDegradation = isInventoryItemAnchorDegradation; +exports.extractSelectedObjectQuotedValue = extractSelectedObjectQuotedValue; exports.extractAddressFilters = extractAddressFilters; const iconv_lite_1 = __importDefault(require("iconv-lite")); const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i; @@ -836,16 +839,47 @@ function isLowQualityInventoryItemAnchorValue(rawValue) { return true; } const lowQualityTokens = new Set([ + "в", + "во", + "на", + "по", + "у", + "от", + "из", + "для", + "и", + "или", + "это", + "этот", + "эту", + "его", + "ее", + "её", + "итог", + "итоге", + "итогу", "сейчас", "лежат", "лежит", "лежали", + "был", + "была", + "было", + "были", "куплен", "куплена", "куплены", + "куплено", "продан", "продана", "проданы", + "продано", + "продали", + "реализован", + "реализована", + "реализованы", + "реализовано", + "реализовали", "документам", "документами", "документы", @@ -865,6 +899,37 @@ function isLowQualityInventoryItemAnchorValue(rawValue) { .filter((token) => !lowQualityTokens.has(token)); return meaningfulTokens.length === 0; } +function normalizeInventoryItemAnchorForComparison(rawValue) { + return cleanupAnchorValue(rawValue) + .trim() + .toLowerCase() + .replace(/С‘/g, "Рµ") + .replace(/\s+/g, " "); +} +function tokenizeInventoryItemAnchorForComparison(rawValue) { + return normalizeInventoryItemAnchorForComparison(rawValue) + .split(/[^a-zа-я0-9]+/iu) + .map((token) => token.trim()) + .filter(Boolean); +} +function isInventoryItemAnchorDegradation(sourceValue, candidateValue) { + const sourceNormalized = normalizeInventoryItemAnchorForComparison(sourceValue); + const candidateNormalized = normalizeInventoryItemAnchorForComparison(candidateValue); + if (!sourceNormalized || !candidateNormalized || sourceNormalized === candidateNormalized) { + return false; + } + if (isLowQualityInventoryItemAnchorValue(candidateNormalized)) { + return true; + } + if (sourceNormalized.includes(candidateNormalized) && candidateNormalized.length < sourceNormalized.length) { + return true; + } + const sourceTokens = tokenizeInventoryItemAnchorForComparison(sourceNormalized); + const candidateTokens = tokenizeInventoryItemAnchorForComparison(candidateNormalized); + return (candidateTokens.length > 0 && + candidateTokens.length < sourceTokens.length && + candidateTokens.every((token) => sourceTokens.includes(token))); +} function cleanupInventoryItemAnchorValue(value) { return String(value ?? "") .replace(/^['"«»“”„`’‘]+|['"«»“”„`’‘]+$/gu, "") @@ -886,6 +951,31 @@ function trimInventoryItemAnchorTail(rawValue) { } return cleanupInventoryItemAnchorValue(value); } +function extractQuotedAnchorValue(text) { + const patterns = [/[«"]([^«»"\r\n]+)[»"]/u, /'([^'\r\n]+)'/u]; + for (const pattern of patterns) { + const match = String(text ?? "").match(pattern); + const candidate = cleanupInventoryItemAnchorValue(String(match?.[1] ?? "")); + if (candidate) { + return candidate; + } + } + return undefined; +} +function extractUnicodeQuotedAnchorValue(text) { + const patterns = [ + /(?:\u00AB|\u0412\u00AB|")([^\u00AB\u00BB"\r\n]+?)(?:\u00BB|\u0412\u00BB|")/u, + /'([^'\r\n]+)'/u + ]; + for (const pattern of patterns) { + const match = String(text ?? "").match(pattern); + const candidate = cleanupInventoryItemAnchorValue(String(match?.[1] ?? "")); + if (candidate) { + return candidate; + } + } + return undefined; +} function extractSelectedObjectQuotedValue(text) { const patterns = [ /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*[«"]([^»"\r\n]+)[»"]/iu, @@ -898,7 +988,7 @@ function extractSelectedObjectQuotedValue(text) { return candidate; } } - return undefined; + return extractUnicodeQuotedAnchorValue(text) ?? extractQuotedAnchorValue(text); } function extractInventoryItemFromSelectedObject(text) { const selectedObject = extractSelectedObjectQuotedValue(text); @@ -923,6 +1013,10 @@ function extractInventoryItemAnchor(text) { if (selectedObjectItem) { return selectedObjectItem; } + const quotedItem = extractUnicodeQuotedAnchorValue(text) ?? extractQuotedAnchorValue(text); + if (quotedItem && !isLowQualityInventoryItemAnchorValue(quotedItem)) { + return quotedItem; + } const patterns = [ /(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s*[«"']([^«»"'?\r\n]+)[»"'](?=$|[\s,.;:!?])/iu, /(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s+([^\r\n,.;:!?]+?)(?=\s+(?:на|по|у|от|из|для|и|когда|через|сейчас|еще|ещё|котор|которые|который|покупателю|поставщика|поставщику|за|в)\b|[:?]|$)/iu diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index d677051..5805400 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1,6 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.resolveAddressIntent = resolveAddressIntent; +const inventoryLifecycleCueHelpers_1 = require("./inventoryLifecycleCueHelpers"); const RECEIVABLES_STRONG = [ "кто должен нам", "кто нам должен", @@ -1345,21 +1346,19 @@ function hasSelectedObjectInventoryCue(text) { return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+нему|по\s+ней|по\s+нему\s+же|по\s+ней\s+же|selected\s+object)/iu.test(text); } function hasSelectedObjectInventoryProvenanceSignal(text) { - return (hasSelectedObjectInventoryCue(text) && - /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test(text)); + return hasSelectedObjectInventoryCue(text) && (0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(text); } function hasSelectedObjectInventoryPurchaseDocumentsSignal(text) { return (hasSelectedObjectInventoryCue(text) && /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(text)); } function hasSelectedObjectInventorySaleTraceSignal(text) { - return (hasSelectedObjectInventoryCue(text) && - /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|куда\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|куда\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale)/iu.test(text)); + return hasSelectedObjectInventoryCue(text) && (0, inventoryLifecycleCueHelpers_1.hasInventorySaleCue)(text); } function hasInventoryProvenanceSignalV2(text) { const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:ок|ки)|склад)/iu.test(text); - const hasSupplierCue = /(?:от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставщик|supplier|vendor)/iu.test(text); - const hasPurchaseCue = /(?:куплен(?:ы|а|о)?|закупк|происхождени|откуда|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:ы|а)?|purchase\s+provenance|purchase\s+date)/iu.test(text); + const hasSupplierCue = (0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(text) || /кем\s+поставлен/iu.test(text); + const hasPurchaseCue = /(?:куплен(?:ы|а|о)?|закупк|происхождени|откуда|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:ы|а)?|purchase\s+provenance|purchase\s+date)/iu.test(text) || (0, inventoryLifecycleCueHelpers_1.hasInventoryPurchaseStem)(text); return hasItemCue && hasSupplierCue && hasPurchaseCue; } function hasInventoryPurchaseDateSignal(text) { diff --git a/llm_normalizer/backend/dist/services/addressQueryClassifier.js b/llm_normalizer/backend/dist/services/addressQueryClassifier.js index fc0a74e..45b562d 100644 --- a/llm_normalizer/backend/dist/services/addressQueryClassifier.js +++ b/llm_normalizer/backend/dist/services/addressQueryClassifier.js @@ -1,6 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.detectAddressQuestionMode = detectAddressQuestionMode; +const inventoryLifecycleCueHelpers_1 = require("./inventoryLifecycleCueHelpers"); const ADDRESS_ACTION_TOKENS = [ "show", "list", @@ -277,7 +278,10 @@ function hasSelectedObjectInventoryFollowupSignal(text) { if (!/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)/iu.test(text)) { return false; } - return /(?:у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:поставил|продал)|кому\s+(?:продали|реализовали)|когда\s+(?:примерно\s+)?купили|по\s+каким\s+документам\s+.*купили)/iu.test(text); + return ((0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(text) || + (0, inventoryLifecycleCueHelpers_1.hasInventorySaleCue)(text) || + /(?:кто\s+(?:поставил|продал)|по\s+каким\s+документам\s+.*купили)/iu.test(text) || + (/\bкогда\b/iu.test(text) && (0, inventoryLifecycleCueHelpers_1.hasInventoryPurchaseStem)(text))); } function hasDocsOrBankSignal(text) { return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|поступлен|списан|транзак|transactions?|bank\s+ops|bank\s+operations?)/iu.test(text); diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 739bb4d..98acdbc 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -1611,15 +1611,11 @@ function shouldBoostAutoBroadenedLimit(intent) { intent === "inventory_aging_by_purchase_date"); } function shouldClearAsOfDateForHistoryRecovery(intent) { - return (intent === "inventory_purchase_provenance_for_item" || - intent === "inventory_purchase_documents_for_item" || - intent === "inventory_sale_trace_for_item" || + return (intent === "inventory_sale_trace_for_item" || intent === "inventory_purchase_to_sale_chain"); } function shouldDetachLifecycleExecutionFromSnapshotContext(intent, reasons) { - if (intent !== "inventory_purchase_provenance_for_item" && - intent !== "inventory_purchase_documents_for_item" && - intent !== "inventory_sale_trace_for_item" && + if (intent !== "inventory_sale_trace_for_item" && intent !== "inventory_purchase_to_sale_chain") { return false; } diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index 584f5fa..289f2ab 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -42,6 +42,42 @@ __WHERE_CLAUSE__ УПОРЯДОЧИТЬ ПО Движения.Период __ORDER_DIRECTION__ `; +const INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE = ` +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + Товары.Ссылка.Дата КАК Период, + ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор, + "" КАК СчетДт, + "41.01" КАК СчетКт, + Товары.Сумма КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура, + ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент, + ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор, + ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация, + Товары.Количество КАК Количество +ИЗ + Документ.РеализацияТоваровУслуг.Товары КАК Товары +__WHERE_CLAUSE__ +УПОРЯДОЧИТЬ ПО + Товары.Ссылка.Дата __ORDER_DIRECTION__ +`; +const INVENTORY_PURCHASE_DOCUMENTS_QUERY_TEMPLATE = ` +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + Товары.Ссылка.Дата КАК Период, + ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор, + "41.01" КАК СчетДт, + "" КАК СчетКт, + Товары.Сумма КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура, + ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент, + ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор, + ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация, + Товары.Количество КАК Количество +ИЗ + Документ.ПоступлениеТоваровУслуг.Товары КАК Товары +__WHERE_CLAUSE__ +УПОРЯДОЧИТЬ ПО + Товары.Ссылка.Дата __ORDER_DIRECTION__ +`; const PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ __AS_OF_EXPR__ КАК Период, @@ -938,15 +974,6 @@ function toDateTimeExpr(isoDate, endOfDay) { function toQueryStringLiteral(value) { return String(value ?? "").replace(/"/g, '""'); } -function buildOrganizationPresentationCondition(filters, fieldPath) { - const organization = typeof filters.organization === "string" && filters.organization.trim().length > 0 - ? filters.organization.trim() - : ""; - if (!organization) { - return null; - } - return `ПРЕДСТАВЛЕНИЕ(${fieldPath}) = "${toQueryStringLiteral(organization)}"`; -} function buildWhereClause(filters, fieldPath, extraConditions = []) { const periodFromExpr = typeof filters.period_from === "string" && filters.period_from.trim().length > 0 ? toDateTimeExpr(filters.period_from, false) @@ -1087,10 +1114,40 @@ function buildInventoryMovementQuery(filters, resolvedLimit, side) { : side === "kt" ? creditPredicate : `(${debitPredicate} ИЛИ ${creditPredicate})`; - const organizationCondition = buildOrganizationPresentationCondition(filters, "Движения.Организация"); return INVENTORY_MOVEMENTS_QUERY_TEMPLATE .replace("__LIMIT__", String(resolvedLimit)) - .replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", [inventoryCondition, organizationCondition].filter((item) => Boolean(item)))) + .replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", [inventoryCondition])) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); +} +function buildInventoryItemReferenceCondition(filters, fieldPaths) { + const item = typeof filters.item === "string" ? filters.item.trim() : ""; + if (!item) { + return null; + } + const escapedItem = toQueryStringLiteral(item); + const referenceSubquery = `(ВЫБРАТЬ Номенклатура.Ссылка ИЗ Справочник.Номенклатура КАК Номенклатура ` + + `ГДЕ Номенклатура.Наименование = "${escapedItem}")`; + const clauses = fieldPaths + .map((fieldPath) => String(fieldPath ?? "").trim()) + .filter((fieldPath) => fieldPath.length > 0) + .map((fieldPath) => `${fieldPath} В ${referenceSubquery}`); + if (clauses.length === 0) { + return null; + } + return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`; +} +function buildInventorySaleDocumentQuery(filters, resolvedLimit) { + const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]); + return INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE + .replace("__LIMIT__", String(resolvedLimit)) + .replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Товары.Ссылка.Дата", ['Товары.Ссылка.Проведен = ИСТИНА', itemCondition].filter((item) => Boolean(item)))) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); +} +function buildInventoryPurchaseDocumentQuery(filters, resolvedLimit) { + const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]); + return INVENTORY_PURCHASE_DOCUMENTS_QUERY_TEMPLATE + .replace("__LIMIT__", String(resolvedLimit)) + .replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Товары.Ссылка.Дата", ['Товары.Ссылка.Проведен = ИСТИНА', itemCondition].filter((item) => Boolean(item)))) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); } function shouldBoostLimitForAllTimeCounterparty(filters) { @@ -1269,13 +1326,13 @@ function buildAddressRecipePlan(recipe, filters) { .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); })() : recipe.query_template === "inventory_purchase_provenance_profile" - ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") + ? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit) : recipe.query_template === "inventory_purchase_documents_profile" - ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") + ? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit) : recipe.query_template === "inventory_supplier_stock_overlap_profile" ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") : recipe.query_template === "inventory_sale_trace_profile" - ? buildInventoryMovementQuery(filters, resolvedLimit, "kt") + ? buildInventorySaleDocumentQuery(filters, resolvedLimit) : recipe.query_template === "inventory_purchase_to_sale_chain_profile" ? buildInventoryMovementQuery(filters, resolvedLimit, "either") : recipe.query_template === "inventory_aging_by_purchase_date_profile" diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index 6f8fb20..76abf35 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -3140,6 +3140,7 @@ function composeFactualReply(intent, rows, options = {}) { const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row)); const summary = summarizeInventoryTraceRows(purchaseRows); const itemLabel = summary.item ?? "товар не определен"; + const boundedAsOfLabel = asOfDate ? formatDateRu(asOfDate) : null; const purchaseDateActionFocus = hasInventoryPurchaseDateActionFocus(options.userMessage); if (purchaseDateActionFocus) { const firstPurchaseDate = inventoryTraceDateLabel(summary.firstPeriod); @@ -3148,7 +3149,9 @@ function composeFactualReply(intent, rows, options = {}) { ? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.` : summary.firstPeriod === summary.lastPeriod ? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.` - : `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`; + : boundedAsOfLabel + ? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.` + : `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`; const lines = [directAnswerLine]; if (purchaseRows.length > 0) { lines.push("", "Подтверждение:"); @@ -3165,8 +3168,11 @@ function composeFactualReply(intent, rows, options = {}) { if (summary.documents.length > 0) { lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`); } - if (summary.firstPeriod && asOfDate && summary.firstPeriod < asOfDate) { - lines.push(`- Дата вопроса по остатку: ${formatDateRu(asOfDate)}; дата закупки показана по подтвержденному закупочному следу.`); + if (boundedAsOfLabel) { + lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`); + } + if (summary.counterparties.length > 1) { + lines.push(`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`); } } return { @@ -3179,33 +3185,51 @@ function composeFactualReply(intent, rows, options = {}) { } }; } - const directAnswerLine = summary.counterparties.length === 1 - ? `Товар ${itemLabel} по доступным закупочным движениям связан с поставщиком: ${summary.counterparties[0]}.` - : summary.counterparties.length > 1 - ? `По доступным закупочным движениям по товару ${itemLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.` - : `По товару ${itemLabel} найден закупочный след, но поставщик не материализован отдельным полем в текущем exact-контуре.`; + const directAnswerLine = purchaseRows.length <= 0 + ? boundedAsOfLabel + ? `По позиции ${itemLabel} подтвержденные закупочные документы до ${boundedAsOfLabel} не найдены.` + : `По позиции ${itemLabel} подтвержденные закупочные документы не найдены.` + : summary.counterparties.length === 1 + ? boundedAsOfLabel + ? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден поставщик: ${summary.counterparties[0]}.` + : `По позиции ${itemLabel} подтвержден поставщик: ${summary.counterparties[0]}.` + : summary.counterparties.length > 1 + ? boundedAsOfLabel + ? `По позиции ${itemLabel} до ${boundedAsOfLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.` + : `По позиции ${itemLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.` + : boundedAsOfLabel + ? `По позиции ${itemLabel} до ${boundedAsOfLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.` + : `По позиции ${itemLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.`; const lines = [directAnswerLine, "", "Подтверждение:"]; - lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`); - lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`); - lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`); - lines.push(`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`); - if (summary.counterparties.length === 1) { - lines.push(`- По доступным закупочным движениям товар связан с поставщиком: ${summary.counterparties[0]}.`); + if (purchaseRows.length > 0) { + lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`); + lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`); + lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`); + lines.push(`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`); + if (summary.counterparties.length === 1) { + lines.push(`- Поставщик в найденных документах: ${summary.counterparties[0]}.`); + } + else if (summary.counterparties.length > 1) { + lines.push(`- Поставщики в найденных документах: ${summary.counterparties.slice(0, 6).join("; ")}.`); + } + else { + lines.push("- Закупочные документы найдены, но поставщик в них не выделен отдельным полем."); + } + if (boundedAsOfLabel) { + lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`); + } + if (summary.counterparties.length > 1) { + lines.push(`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`); + } } - else if (summary.counterparties.length > 1) { - lines.push(`- По доступным закупочным движениям найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`); - } - else if (purchaseRows.length > 0) { - lines.push("- Закупочные документы найдены, но поставщик не материализован отдельным полем в текущем exact-контуре."); + else if (boundedAsOfLabel) { + lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`); } if (summary.documents.length > 0) { lines.push("", "Опорные документы:", ...formatInventoryTraceRows(purchaseRows, 8)); } - if (purchaseRows.length > 0) { - lines.push("", "Сервисно:", "- Без партионности этот контур показывает документально наблюдаемый закупочный след, а не лот-level происхождение текущего остатка."); - } return { - responseType: purchaseRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY", + responseType: "FACTUAL_SUMMARY", text: joinLines(lines), semantics: { result_mode: "confirmed_balance", diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index ccc0563..405d977 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -1,11 +1,18 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); +exports.hasInventorySupplierFollowupCue = hasInventorySupplierFollowupCue; +exports.hasInventoryPurchaseDocumentsFollowupCue = hasInventoryPurchaseDocumentsFollowupCue; +exports.hasInventoryPurchaseDateFollowupCue = hasInventoryPurchaseDateFollowupCue; +exports.hasBareInventoryPurchaseDateFollowupCue = hasBareInventoryPurchaseDateFollowupCue; +exports.hasInventorySaleFollowupCue = hasInventorySaleFollowupCue; +exports.hasInventoryPurchaseToSaleChainFollowupCue = hasInventoryPurchaseToSaleChainFollowupCue; exports.hasAddressFollowupContextSignal = hasAddressFollowupContextSignal; exports.runAddressDecomposeStage = runAddressDecomposeStage; const addressQueryClassifier_1 = require("../addressQueryClassifier"); const addressQueryShapeClassifier_1 = require("../addressQueryShapeClassifier"); const addressIntentResolver_1 = require("../addressIntentResolver"); const addressFilterExtractor_1 = require("../addressFilterExtractor"); +const inventoryLifecycleCueHelpers_1 = require("../inventoryLifecycleCueHelpers"); const semanticHintOverlay_1 = require("./semanticHintOverlay"); function hasExplicitPeriodWindow(filters) { return ((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) || @@ -402,13 +409,14 @@ function hasSelectedObjectInventorySignal(text) { return /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(String(text ?? "")); } function hasInventorySupplierFollowupCue(text) { - return /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test(String(text ?? "")); + return (0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(String(text ?? "")); } function hasInventoryPurchaseDocumentsFollowupCue(text) { return /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(String(text ?? "")); } function hasInventoryPurchaseDateFollowupCue(text) { - return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test(String(text ?? "")); + const value = String(text ?? ""); + return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test(value) || (/когда/iu.test(value) && (0, inventoryLifecycleCueHelpers_1.hasInventoryPurchaseStem)(value)); } function hasBareInventoryPurchaseDateFollowupCue(text) { const normalized = String(text ?? "").trim().toLowerCase(); @@ -418,7 +426,7 @@ function hasBareInventoryPurchaseDateFollowupCue(text) { return /(?:^|\s)(?:когда|а\s+когда|ну\s+когда)(?=$|[\s,.;:!?])/iu.test(normalized) && normalized.split(/\s+/).filter(Boolean).length <= 3; } function hasInventorySaleFollowupCue(text) { - return /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кому\s+дальше\s+продали|куда\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|куда\s+ушла\s+позиция|куда\s+ушел\s+товар|кто\s+купил|buyer|покупател)/iu.test(String(text ?? "")); + return (0, inventoryLifecycleCueHelpers_1.hasInventorySaleCue)(String(text ?? "")); } function hasInventoryPurchaseToSaleChainFollowupCue(text) { return /(?:через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale)/iu.test(String(text ?? "")); @@ -608,12 +616,34 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { intent === "inventory_purchase_documents_for_item" || intent === "inventory_sale_trace_for_item" || intent === "inventory_purchase_to_sale_chain" || - intent === "inventory_aging_by_purchase_date") && - !toNonEmptyString(merged.item)) { + intent === "inventory_aging_by_purchase_date")) { const inheritedItem = previousItem ?? previousAnchorItem; - if (inheritedItem && intent !== "inventory_aging_by_purchase_date") { + const explicitQuotedItem = toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(userMessage)); + const currentItem = toNonEmptyString(merged.item); + const shouldAdoptExplicitQuotedItem = Boolean(explicitQuotedItem) && + (!currentItem || + currentItem !== explicitQuotedItem || + (0, addressFilterExtractor_1.isInventoryItemAnchorDegradation)(explicitQuotedItem ?? "", currentItem ?? "")); + if (explicitQuotedItem && shouldAdoptExplicitQuotedItem) { + merged.item = explicitQuotedItem; + reasons.push(currentItem ? "item_replaced_from_explicit_quote" : "item_from_explicit_quote"); + } + const effectiveCurrentItem = toNonEmptyString(merged.item); + const hasExplicitDifferentQuotedItem = Boolean(explicitQuotedItem) && + Boolean(inheritedItem) && + explicitQuotedItem !== inheritedItem; + const shouldInheritItem = Boolean(inheritedItem) && + intent !== "inventory_aging_by_purchase_date" && + !hasExplicitDifferentQuotedItem && + (!effectiveCurrentItem || + ((0, addressFilterExtractor_1.isLowQualityInventoryItemAnchorValue)(effectiveCurrentItem) && + !(0, addressFilterExtractor_1.isLowQualityInventoryItemAnchorValue)(inheritedItem ?? "")) || + (effectiveCurrentItem && + inheritedItem && + (0, addressFilterExtractor_1.isInventoryItemAnchorDegradation)(inheritedItem, effectiveCurrentItem))); + if (shouldInheritItem && inheritedItem) { merged.item = inheritedItem; - reasons.push("item_from_followup_context"); + reasons.push(effectiveCurrentItem ? "item_replaced_from_followup_context" : "item_from_followup_context"); } } if (sameDateRequested) { diff --git a/llm_normalizer/backend/dist/services/address_runtime/semanticHintOverlay.js b/llm_normalizer/backend/dist/services/address_runtime/semanticHintOverlay.js index 1d20ee9..73e8536 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/semanticHintOverlay.js +++ b/llm_normalizer/backend/dist/services/address_runtime/semanticHintOverlay.js @@ -2,6 +2,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.normalizeAddressLlmSemanticHints = normalizeAddressLlmSemanticHints; exports.applyAddressLlmSemanticHintsToExtraction = applyAddressLlmSemanticHintsToExtraction; +const addressFilterExtractor_1 = require("../addressFilterExtractor"); function toNonEmptyString(value) { if (value === null || value === undefined) { return null; @@ -66,6 +67,29 @@ function applyDateScopeHint(frame, dateScopeKind) { frame.date_basis_hint = "implicit_current_snapshot"; } } +function normalizeInventoryItemAnchorValue(value) { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/\s+/g, " "); +} +function shouldApplyInventoryItemSemanticHint(currentItemValue, hintedItemValue) { + if (!hintedItemValue || (0, addressFilterExtractor_1.isLowQualityInventoryItemAnchorValue)(hintedItemValue)) { + return false; + } + if (!currentItemValue || (0, addressFilterExtractor_1.isLowQualityInventoryItemAnchorValue)(currentItemValue)) { + return true; + } + if ((0, addressFilterExtractor_1.isInventoryItemAnchorDegradation)(currentItemValue, hintedItemValue)) { + return false; + } + const currentNormalized = normalizeInventoryItemAnchorValue(currentItemValue); + const hintedNormalized = normalizeInventoryItemAnchorValue(hintedItemValue); + if (currentNormalized === hintedNormalized) { + return false; + } + return hintedNormalized.includes(currentNormalized); +} function applyAddressLlmSemanticHintsToExtraction(extraction, semanticHintsInput) { const semanticHints = normalizeAddressLlmSemanticHints(semanticHintsInput); if (!semanticHints) { @@ -123,11 +147,17 @@ function applyAddressLlmSemanticHintsToExtraction(extraction, semanticHintsInput semanticFrame.anchor_value = scopeTargetText; } if (semanticHints.scope_target_kind === "item" && scopeTargetText) { - extractedFilters.item = scopeTargetText; - pushWarning(warnings, "item_from_llm_semantics"); - semanticFrame.scope_kind = "explicit_anchor"; - semanticFrame.anchor_kind = "item"; - semanticFrame.anchor_value = scopeTargetText; + const currentItemValue = toNonEmptyString(extractedFilters.item); + if (shouldApplyInventoryItemSemanticHint(currentItemValue, scopeTargetText)) { + extractedFilters.item = scopeTargetText; + pushWarning(warnings, "item_from_llm_semantics"); + semanticFrame.scope_kind = "explicit_anchor"; + semanticFrame.anchor_kind = "item"; + semanticFrame.anchor_value = scopeTargetText; + } + else if (currentItemValue && currentItemValue !== scopeTargetText) { + pushWarning(warnings, "item_llm_semantics_ignored"); + } } return { ...extraction, diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index a78c532..a21edcb 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -57,6 +57,7 @@ const addressQueryService_1 = __importStar(require("./addressQueryService")); const addressQueryClassifier_1 = __importStar(require("./addressQueryClassifier")); const addressIntentResolver_1 = __importStar(require("./addressIntentResolver")); const addressFilterExtractor_1 = __importStar(require("./addressFilterExtractor")); +const decomposeStage_1 = __importStar(require("./address_runtime/decomposeStage")); const predecomposeContract_1 = __importStar(require("./address_runtime/predecomposeContract")); const openaiResponsesClient_1 = __importStar(require("./openaiResponsesClient")); const addressMcpClient_1 = __importStar(require("./addressMcpClient")); @@ -2767,8 +2768,15 @@ function hasShortInventoryObjectFollowupSignal(userMessage) { if (minTokens > 8) { return false; } + const hasDirectSaleFollowupCue = (sample) => /(?:кому|каму|куда)(?:\s+\S+){0,4}\s+(?:продали|продано|продан(?:о|а|ы)?|реализовали|реализован(?:о|а|ы)?)|(?:продали|продано|реализовали|реализован(?:о|а|ы)?)(?:\s+\S+){0,4}\s+(?:кому|каму|куда)|(?:^|\s)(?:продано|продали|реализовано|реализовали)(?=$|[\s,.;:!?])/iu.test(sample); return samples.some((sample) => /^(?:кто|когда|документы|сумма|поставщик|покупатель)(?:\?)?$/iu.test(sample) || - /^(?:когда\s+(?:примерно\s+)?купили(?:\s+ее)?|каким\s+документом|покажи\s+документы|по\s+каким\s+документам|все\s+закупки|все\s+поступления|кому\s+(?:мы\s+)?продали|кто\s+купил|цепочка|путь\s+товара)(?:\?)?$/iu.test(sample)); + hasDirectSaleFollowupCue(sample) || + (0, decomposeStage_1.hasInventorySupplierFollowupCue)(sample) || + (0, decomposeStage_1.hasInventoryPurchaseDocumentsFollowupCue)(sample) || + (0, decomposeStage_1.hasInventoryPurchaseDateFollowupCue)(sample) || + (0, decomposeStage_1.hasBareInventoryPurchaseDateFollowupCue)(sample) || + (0, decomposeStage_1.hasInventorySaleFollowupCue)(sample) || + (0, decomposeStage_1.hasInventoryPurchaseToSaleChainFollowupCue)(sample)); } function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) { const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase()); @@ -3408,6 +3416,13 @@ function resolveRequiredAnchorTypeForIntent(intent) { if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") { return "contract"; } + if (intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_sale_trace_for_item" || + intent === "inventory_purchase_to_sale_chain" || + intent === "inventory_aging_by_purchase_date") { + return "item"; + } return null; } function evaluateAddressAnchorQuality(message) { @@ -3425,7 +3440,9 @@ function evaluateAddressAnchorQuality(message) { const extracted = (0, addressFilterExtractor_1.extractAddressFilters)(String(message ?? ""), intent); const anchorValue = anchorType === "counterparty" ? toNonEmptyString(extracted?.extracted_filters?.counterparty) - : toNonEmptyString(extracted?.extracted_filters?.contract); + : anchorType === "contract" + ? toNonEmptyString(extracted?.extracted_filters?.contract) + : toNonEmptyString(extracted?.extracted_filters?.item); if (!anchorValue) { return { intent, @@ -3436,7 +3453,9 @@ function evaluateAddressAnchorQuality(message) { } const lowQuality = anchorType === "counterparty" ? isLowQualityPredecomposeCounterpartyAnchor(anchorValue) - : isLowQualityPredecomposeContractAnchor(anchorValue); + : anchorType === "contract" + ? isLowQualityPredecomposeContractAnchor(anchorValue) + : (0, addressFilterExtractor_1.isLowQualityInventoryItemAnchorValue)(anchorValue); return { intent, anchorType, @@ -3660,6 +3679,14 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage); const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate); const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent; + const sourceSelectedObjectItemAnchorValue = toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(userMessage)) ?? + toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(repairedSourceMessage || userMessage)); + const candidateSemanticItemAnchorValue = (((sameIntentForAnchorSafety && + sourceAnchorQuality.anchorType === "item") || + Boolean(sourceSelectedObjectItemAnchorValue)) && + candidateMeta?.semanticHints?.scope_target_kind === "item" + ? toNonEmptyString(candidateMeta.semanticHints.scope_target_text) + : null); const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety && sourceAnchorQuality.anchorType === "counterparty" && sourceAnchorQuality.quality >= 2 && @@ -3684,6 +3711,25 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage semanticHints: candidateMeta?.semanticHints ?? null }, userMessage); } + const itemSemanticAnchorDegradedByCandidate = (sameIntentForAnchorSafety || + Boolean(sourceSelectedObjectItemAnchorValue)) && + Boolean(sourceSelectedObjectItemAnchorValue ?? sourceAnchorQuality.anchorValue) && + Boolean(candidateSemanticItemAnchorValue) && + (0, addressFilterExtractor_1.isInventoryItemAnchorDegradation)(sourceSelectedObjectItemAnchorValue ?? sourceAnchorQuality.anchorValue ?? "", candidateSemanticItemAnchorValue ?? ""); + if (itemSemanticAnchorDegradedByCandidate) { + return attachAddressPredecomposeContract({ + ...baseMeta, + attempted: true, + applied: false, + traceId: normalized?.trace_id ?? null, + llmCanonicalCandidateDetected: true, + effectiveMessage: userMessage, + reason: "normalized_fragment_rejected_anchor_degradation", + fallbackRuleHit: null, + sanitizedUserMessage, + semanticHints: candidateMeta?.semanticHints ?? null + }, userMessage); + } const anchorDegradedByCandidate = sameIntentForAnchorSafety && sourceAnchorQuality.anchorType && sourceAnchorQuality.quality >= 2 && diff --git a/llm_normalizer/backend/dist/services/inventoryLifecycleCueHelpers.js b/llm_normalizer/backend/dist/services/inventoryLifecycleCueHelpers.js new file mode 100644 index 0000000..9734a7c --- /dev/null +++ b/llm_normalizer/backend/dist/services/inventoryLifecycleCueHelpers.js @@ -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); +} diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index ccea138..76d5c32 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -943,7 +943,7 @@ function usesRecipeDefaultLimit(intent: AddressIntent): boolean { ); } -function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean { +export function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean { const value = cleanupAnchorValue(rawValue) .trim() .toLowerCase() @@ -959,16 +959,47 @@ function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean { return true; } const lowQualityTokens = new Set([ + "в", + "во", + "на", + "по", + "у", + "от", + "из", + "для", + "и", + "или", + "это", + "этот", + "эту", + "его", + "ее", + "её", + "итог", + "итоге", + "итогу", "сейчас", "лежат", "лежит", "лежали", + "был", + "была", + "было", + "были", "куплен", "куплена", "куплены", + "куплено", "продан", "продана", "проданы", + "продано", + "продали", + "реализован", + "реализована", + "реализованы", + "реализовано", + "реализовали", "документам", "документами", "документы", @@ -989,6 +1020,42 @@ function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean { return meaningfulTokens.length === 0; } +function normalizeInventoryItemAnchorForComparison(rawValue: string): string { + return cleanupAnchorValue(rawValue) + .trim() + .toLowerCase() + .replace(/С‘/g, "Рµ") + .replace(/\s+/g, " "); +} + +function tokenizeInventoryItemAnchorForComparison(rawValue: string): string[] { + return normalizeInventoryItemAnchorForComparison(rawValue) + .split(/[^a-zа-я0-9]+/iu) + .map((token) => token.trim()) + .filter(Boolean); +} + +export function isInventoryItemAnchorDegradation(sourceValue: string, candidateValue: string): boolean { + const sourceNormalized = normalizeInventoryItemAnchorForComparison(sourceValue); + const candidateNormalized = normalizeInventoryItemAnchorForComparison(candidateValue); + if (!sourceNormalized || !candidateNormalized || sourceNormalized === candidateNormalized) { + return false; + } + if (isLowQualityInventoryItemAnchorValue(candidateNormalized)) { + return true; + } + if (sourceNormalized.includes(candidateNormalized) && candidateNormalized.length < sourceNormalized.length) { + return true; + } + const sourceTokens = tokenizeInventoryItemAnchorForComparison(sourceNormalized); + const candidateTokens = tokenizeInventoryItemAnchorForComparison(candidateNormalized); + return ( + candidateTokens.length > 0 && + candidateTokens.length < sourceTokens.length && + candidateTokens.every((token) => sourceTokens.includes(token)) + ); +} + function cleanupInventoryItemAnchorValue(value: string): string { return String(value ?? "") .replace(/^['"«»“”„`’‘]+|['"«»“”„`’‘]+$/gu, "") @@ -1012,7 +1079,34 @@ function trimInventoryItemAnchorTail(rawValue: string): string { return cleanupInventoryItemAnchorValue(value); } -function extractSelectedObjectQuotedValue(text: string): string | undefined { +function extractQuotedAnchorValue(text: string): string | undefined { + const patterns = [/[«"]([^«»"\r\n]+)[»"]/u, /'([^'\r\n]+)'/u]; + for (const pattern of patterns) { + const match = String(text ?? "").match(pattern); + const candidate = cleanupInventoryItemAnchorValue(String(match?.[1] ?? "")); + if (candidate) { + return candidate; + } + } + return undefined; +} + +function extractUnicodeQuotedAnchorValue(text: string): string | undefined { + const patterns = [ + /(?:\u00AB|\u0412\u00AB|")([^\u00AB\u00BB"\r\n]+?)(?:\u00BB|\u0412\u00BB|")/u, + /'([^'\r\n]+)'/u + ]; + for (const pattern of patterns) { + const match = String(text ?? "").match(pattern); + const candidate = cleanupInventoryItemAnchorValue(String(match?.[1] ?? "")); + if (candidate) { + return candidate; + } + } + return undefined; +} + +export function extractSelectedObjectQuotedValue(text: string): string | undefined { const patterns = [ /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*[«"]([^»"\r\n]+)[»"]/iu, /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*:\s*[«"]([^»"\r\n]+)[»"]/iu @@ -1024,7 +1118,7 @@ function extractSelectedObjectQuotedValue(text: string): string | undefined { return candidate; } } - return undefined; + return extractUnicodeQuotedAnchorValue(text) ?? extractQuotedAnchorValue(text); } function extractInventoryItemFromSelectedObject(text: string): string | undefined { @@ -1051,6 +1145,10 @@ function extractInventoryItemAnchor(text: string): string | undefined { if (selectedObjectItem) { return selectedObjectItem; } + const quotedItem = extractUnicodeQuotedAnchorValue(text) ?? extractQuotedAnchorValue(text); + if (quotedItem && !isLowQualityInventoryItemAnchorValue(quotedItem)) { + return quotedItem; + } const patterns = [ /(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s*[«"']([^«»"'?\r\n]+)[»"'](?=$|[\s,.;:!?])/iu, /(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s+([^\r\n,.;:!?]+?)(?=\s+(?:на|по|у|от|из|для|и|когда|через|сейчас|еще|ещё|котор|которые|который|покупателю|поставщика|поставщику|за|в)\b|[:?]|$)/iu diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index dfe1d93..8d61abf 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -1,4 +1,5 @@ import type { AddressIntentResolution } from "../types/addressQuery"; +import { hasInventoryPurchaseStem, hasInventorySaleCue, hasInventorySupplierCue } from "./inventoryLifecycleCueHelpers"; const RECEIVABLES_STRONG = [ "кто должен нам", @@ -1616,12 +1617,7 @@ function hasSelectedObjectInventoryCue(text: string): boolean { } function hasSelectedObjectInventoryProvenanceSignal(text: string): boolean { - return ( - hasSelectedObjectInventoryCue(text) && - /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test( - text - ) - ); + return hasSelectedObjectInventoryCue(text) && hasInventorySupplierCue(text); } function hasSelectedObjectInventoryPurchaseDocumentsSignal(text: string): boolean { @@ -1634,24 +1630,16 @@ function hasSelectedObjectInventoryPurchaseDocumentsSignal(text: string): boolea } function hasSelectedObjectInventorySaleTraceSignal(text: string): boolean { - return ( - hasSelectedObjectInventoryCue(text) && - /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|куда\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|куда\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale)/iu.test( - text - ) - ); + return hasSelectedObjectInventoryCue(text) && hasInventorySaleCue(text); } function hasInventoryProvenanceSignalV2(text: string): boolean { const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:ок|ки)|склад)/iu.test(text); - const hasSupplierCue = - /(?:от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставщик|supplier|vendor)/iu.test( - text - ); + const hasSupplierCue = hasInventorySupplierCue(text) || /кем\s+поставлен/iu.test(text); const hasPurchaseCue = /(?:куплен(?:ы|а|о)?|закупк|происхождени|откуда|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:ы|а)?|purchase\s+provenance|purchase\s+date)/iu.test( text - ); + ) || hasInventoryPurchaseStem(text); return hasItemCue && hasSupplierCue && hasPurchaseCue; } diff --git a/llm_normalizer/backend/src/services/addressQueryClassifier.ts b/llm_normalizer/backend/src/services/addressQueryClassifier.ts index 9e138e2..694635b 100644 --- a/llm_normalizer/backend/src/services/addressQueryClassifier.ts +++ b/llm_normalizer/backend/src/services/addressQueryClassifier.ts @@ -1,5 +1,7 @@ import type { AddressModeDetection } from "../types/addressQuery"; +import { hasInventoryPurchaseStem, hasInventorySaleCue, hasInventorySupplierCue } from "./inventoryLifecycleCueHelpers"; + const ADDRESS_ACTION_TOKENS = [ "show", "list", @@ -286,8 +288,11 @@ function hasSelectedObjectInventoryFollowupSignal(text: string): boolean { if (!/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)/iu.test(text)) { return false; } - return /(?:у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:поставил|продал)|кому\s+(?:продали|реализовали)|когда\s+(?:примерно\s+)?купили|по\s+каким\s+документам\s+.*купили)/iu.test( - text + return ( + hasInventorySupplierCue(text) || + hasInventorySaleCue(text) || + /(?:кто\s+(?:поставил|продал)|по\s+каким\s+документам\s+.*купили)/iu.test(text) || + (/\bкогда\b/iu.test(text) && hasInventoryPurchaseStem(text)) ); } diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 0a7d56e..d7883c6 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -2008,8 +2008,6 @@ function shouldBoostAutoBroadenedLimit(intent: AddressIntent): boolean { function shouldClearAsOfDateForHistoryRecovery(intent: AddressIntent): boolean { return ( - intent === "inventory_purchase_provenance_for_item" || - intent === "inventory_purchase_documents_for_item" || intent === "inventory_sale_trace_for_item" || intent === "inventory_purchase_to_sale_chain" ); @@ -2020,8 +2018,6 @@ function shouldDetachLifecycleExecutionFromSnapshotContext( reasons: string[] ): boolean { if ( - intent !== "inventory_purchase_provenance_for_item" && - intent !== "inventory_purchase_documents_for_item" && intent !== "inventory_sale_trace_for_item" && intent !== "inventory_purchase_to_sale_chain" ) { diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index 5c3bf92..eddfc98 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -47,6 +47,44 @@ __WHERE_CLAUSE__ Движения.Период __ORDER_DIRECTION__ `; +const INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE = ` +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + Товары.Ссылка.Дата КАК Период, + ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор, + "" КАК СчетДт, + "41.01" КАК СчетКт, + Товары.Сумма КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура, + ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент, + ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор, + ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация, + Товары.Количество КАК Количество +ИЗ + Документ.РеализацияТоваровУслуг.Товары КАК Товары +__WHERE_CLAUSE__ +УПОРЯДОЧИТЬ ПО + Товары.Ссылка.Дата __ORDER_DIRECTION__ +`; + +const INVENTORY_PURCHASE_DOCUMENTS_QUERY_TEMPLATE = ` +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + Товары.Ссылка.Дата КАК Период, + ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор, + "41.01" КАК СчетДт, + "" КАК СчетКт, + Товары.Сумма КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура, + ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент, + ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор, + ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация, + Товары.Количество КАК Количество +ИЗ + Документ.ПоступлениеТоваровУслуг.Товары КАК Товары +__WHERE_CLAUSE__ +УПОРЯДОЧИТЬ ПО + Товары.Ссылка.Дата __ORDER_DIRECTION__ +`; + const PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ __AS_OF_EXPR__ КАК Период, @@ -972,17 +1010,6 @@ function toQueryStringLiteral(value: string): string { return String(value ?? "").replace(/"/g, '""'); } -function buildOrganizationPresentationCondition(filters: AddressFilterSet, fieldPath: string): string | null { - const organization = - typeof filters.organization === "string" && filters.organization.trim().length > 0 - ? filters.organization.trim() - : ""; - if (!organization) { - return null; - } - return `ПРЕДСТАВЛЕНИЕ(${fieldPath}) = "${toQueryStringLiteral(organization)}"`; -} - function buildWhereClause(filters: AddressFilterSet, fieldPath: string, extraConditions: string[] = []): string { const periodFromExpr = typeof filters.period_from === "string" && filters.period_from.trim().length > 0 @@ -1154,15 +1181,56 @@ function buildInventoryMovementQuery( : side === "kt" ? creditPredicate : `(${debitPredicate} ИЛИ ${creditPredicate})`; - const organizationCondition = buildOrganizationPresentationCondition(filters, "Движения.Организация"); return INVENTORY_MOVEMENTS_QUERY_TEMPLATE + .replace("__LIMIT__", String(resolvedLimit)) + .replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", [inventoryCondition])) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); +} + +function buildInventoryItemReferenceCondition(filters: AddressFilterSet, fieldPaths: string[]): string | null { + const item = typeof filters.item === "string" ? filters.item.trim() : ""; + if (!item) { + return null; + } + const escapedItem = toQueryStringLiteral(item); + const referenceSubquery = + `(ВЫБРАТЬ Номенклатура.Ссылка ИЗ Справочник.Номенклатура КАК Номенклатура ` + + `ГДЕ Номенклатура.Наименование = "${escapedItem}")`; + const clauses = fieldPaths + .map((fieldPath) => String(fieldPath ?? "").trim()) + .filter((fieldPath) => fieldPath.length > 0) + .map((fieldPath) => `${fieldPath} В ${referenceSubquery}`); + if (clauses.length === 0) { + return null; + } + return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`; +} + +function buildInventorySaleDocumentQuery(filters: AddressFilterSet, resolvedLimit: number): string { + const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]); + return INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE .replace("__LIMIT__", String(resolvedLimit)) .replace( "__WHERE_CLAUSE__", buildWhereClause( filters, - "Движения.Период", - [inventoryCondition, organizationCondition].filter((item): item is string => Boolean(item)) + "Товары.Ссылка.Дата", + ['Товары.Ссылка.Проведен = ИСТИНА', itemCondition].filter((item): item is string => Boolean(item)) + ) + ) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); +} + +function buildInventoryPurchaseDocumentQuery(filters: AddressFilterSet, resolvedLimit: number): string { + const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]); + return INVENTORY_PURCHASE_DOCUMENTS_QUERY_TEMPLATE + .replace("__LIMIT__", String(resolvedLimit)) + .replace( + "__WHERE_CLAUSE__", + buildWhereClause( + filters, + "Товары.Ссылка.Дата", + ['Товары.Ссылка.Проведен = ИСТИНА', itemCondition].filter((item): item is string => Boolean(item)) ) ) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); @@ -1395,13 +1463,13 @@ export function buildAddressRecipePlan( .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); })() : recipe.query_template === "inventory_purchase_provenance_profile" - ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") + ? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit) : recipe.query_template === "inventory_purchase_documents_profile" - ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") + ? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit) : recipe.query_template === "inventory_supplier_stock_overlap_profile" ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") : recipe.query_template === "inventory_sale_trace_profile" - ? buildInventoryMovementQuery(filters, resolvedLimit, "kt") + ? buildInventorySaleDocumentQuery(filters, resolvedLimit) : recipe.query_template === "inventory_purchase_to_sale_chain_profile" ? buildInventoryMovementQuery(filters, resolvedLimit, "either") : recipe.query_template === "inventory_aging_by_purchase_date_profile" diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index 467da97..166b45c 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -4065,6 +4065,7 @@ export function composeFactualReply( const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row)); const summary = summarizeInventoryTraceRows(purchaseRows); const itemLabel = summary.item ?? "товар не определен"; + const boundedAsOfLabel = asOfDate ? formatDateRu(asOfDate) : null; const purchaseDateActionFocus = hasInventoryPurchaseDateActionFocus(options.userMessage); if (purchaseDateActionFocus) { const firstPurchaseDate = inventoryTraceDateLabel(summary.firstPeriod); @@ -4074,7 +4075,9 @@ export function composeFactualReply( ? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.` : summary.firstPeriod === summary.lastPeriod ? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.` - : `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`; + : boundedAsOfLabel + ? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.` + : `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`; const lines: string[] = [directAnswerLine]; if (purchaseRows.length > 0) { lines.push("", "Подтверждение:"); @@ -4090,8 +4093,13 @@ export function composeFactualReply( if (summary.documents.length > 0) { lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`); } - if (summary.firstPeriod && asOfDate && summary.firstPeriod < asOfDate) { - lines.push(`- Дата вопроса по остатку: ${formatDateRu(asOfDate)}; дата закупки показана по подтвержденному закупочному следу.`); + if (boundedAsOfLabel) { + lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`); + } + if (summary.counterparties.length > 1) { + lines.push( + `- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.` + ); } } return { @@ -4105,35 +4113,50 @@ export function composeFactualReply( }; } const directAnswerLine = - summary.counterparties.length === 1 - ? `Товар ${itemLabel} по доступным закупочным движениям связан с поставщиком: ${summary.counterparties[0]}.` - : summary.counterparties.length > 1 - ? `По доступным закупочным движениям по товару ${itemLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.` - : `По товару ${itemLabel} найден закупочный след, но поставщик не материализован отдельным полем в текущем exact-контуре.`; + purchaseRows.length <= 0 + ? boundedAsOfLabel + ? `По позиции ${itemLabel} подтвержденные закупочные документы до ${boundedAsOfLabel} не найдены.` + : `По позиции ${itemLabel} подтвержденные закупочные документы не найдены.` + : summary.counterparties.length === 1 + ? boundedAsOfLabel + ? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден поставщик: ${summary.counterparties[0]}.` + : `По позиции ${itemLabel} подтвержден поставщик: ${summary.counterparties[0]}.` + : summary.counterparties.length > 1 + ? boundedAsOfLabel + ? `По позиции ${itemLabel} до ${boundedAsOfLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.` + : `По позиции ${itemLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.` + : boundedAsOfLabel + ? `По позиции ${itemLabel} до ${boundedAsOfLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.` + : `По позиции ${itemLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.`; const lines: string[] = [directAnswerLine, "", "Подтверждение:"]; - lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`); - lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`); - lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`); - lines.push(`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`); - if (summary.counterparties.length === 1) { - lines.push(`- По доступным закупочным движениям товар связан с поставщиком: ${summary.counterparties[0]}.`); - } else if (summary.counterparties.length > 1) { - lines.push(`- По доступным закупочным движениям найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`); - } else if (purchaseRows.length > 0) { - lines.push("- Закупочные документы найдены, но поставщик не материализован отдельным полем в текущем exact-контуре."); + if (purchaseRows.length > 0) { + lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`); + lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`); + lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`); + lines.push(`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`); + if (summary.counterparties.length === 1) { + lines.push(`- Поставщик в найденных документах: ${summary.counterparties[0]}.`); + } else if (summary.counterparties.length > 1) { + lines.push(`- Поставщики в найденных документах: ${summary.counterparties.slice(0, 6).join("; ")}.`); + } else { + lines.push("- Закупочные документы найдены, но поставщик в них не выделен отдельным полем."); + } + if (boundedAsOfLabel) { + lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`); + } + if (summary.counterparties.length > 1) { + lines.push( + `- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.` + ); + } + } else if (boundedAsOfLabel) { + lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`); } if (summary.documents.length > 0) { lines.push("", "Опорные документы:", ...formatInventoryTraceRows(purchaseRows, 8)); } - if (purchaseRows.length > 0) { - lines.push( - "", - "Сервисно:", - "- Без партионности этот контур показывает документально наблюдаемый закупочный след, а не лот-level происхождение текущего остатка." - ); - } return { - responseType: purchaseRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY", + responseType: "FACTUAL_SUMMARY", text: joinLines(lines), semantics: { result_mode: "confirmed_balance", diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index de5a288..5df17c5 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -9,7 +9,13 @@ import { detectAddressQuestionMode } from "../addressQueryClassifier"; import { classifyAddressQueryShape } from "../addressQueryShapeClassifier"; import { resolveAddressIntent } from "../addressIntentResolver"; -import { extractAddressFilters } from "../addressFilterExtractor"; +import { + extractSelectedObjectQuotedValue, + extractAddressFilters, + isInventoryItemAnchorDegradation, + isLowQualityInventoryItemAnchorValue +} from "../addressFilterExtractor"; +import { hasInventoryPurchaseStem, hasInventorySaleCue, hasInventorySupplierCue } from "../inventoryLifecycleCueHelpers"; import { applyAddressLlmSemanticHintsToExtraction } from "./semanticHintOverlay"; import type { AddressLlmSemanticHints } from "../../types/addressQuery"; @@ -511,25 +517,24 @@ function hasSelectedObjectInventorySignal(text: string): boolean { return /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(String(text ?? "")); } -function hasInventorySupplierFollowupCue(text: string): boolean { - return /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test( - String(text ?? "") - ); +export function hasInventorySupplierFollowupCue(text: string): boolean { + return hasInventorySupplierCue(String(text ?? "")); } -function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean { +export function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean { return /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test( String(text ?? "") ); } -function hasInventoryPurchaseDateFollowupCue(text: string): boolean { +export function hasInventoryPurchaseDateFollowupCue(text: string): boolean { + const value = String(text ?? ""); return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test( - String(text ?? "") - ); + value + ) || (/когда/iu.test(value) && hasInventoryPurchaseStem(value)); } -function hasBareInventoryPurchaseDateFollowupCue(text: string): boolean { +export function hasBareInventoryPurchaseDateFollowupCue(text: string): boolean { const normalized = String(text ?? "").trim().toLowerCase(); if (!normalized) { return false; @@ -537,13 +542,11 @@ function hasBareInventoryPurchaseDateFollowupCue(text: string): boolean { return /(?:^|\s)(?:когда|а\s+когда|ну\s+когда)(?=$|[\s,.;:!?])/iu.test(normalized) && normalized.split(/\s+/).filter(Boolean).length <= 3; } -function hasInventorySaleFollowupCue(text: string): boolean { - return /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кому\s+дальше\s+продали|куда\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|куда\s+ушла\s+позиция|куда\s+ушел\s+товар|кто\s+купил|buyer|покупател)/iu.test( - String(text ?? "") - ); +export function hasInventorySaleFollowupCue(text: string): boolean { + return hasInventorySaleCue(String(text ?? "")); } -function hasInventoryPurchaseToSaleChainFollowupCue(text: string): boolean { +export function hasInventoryPurchaseToSaleChainFollowupCue(text: string): boolean { return /(?:через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale)/iu.test( String(text ?? "") ); @@ -777,13 +780,38 @@ function mergeFollowupFilters( intent === "inventory_purchase_documents_for_item" || intent === "inventory_sale_trace_for_item" || intent === "inventory_purchase_to_sale_chain" || - intent === "inventory_aging_by_purchase_date") && - !toNonEmptyString(merged.item) + intent === "inventory_aging_by_purchase_date") ) { const inheritedItem = previousItem ?? previousAnchorItem; - if (inheritedItem && intent !== "inventory_aging_by_purchase_date") { + const explicitQuotedItem = toNonEmptyString(extractSelectedObjectQuotedValue(userMessage)); + const currentItem = toNonEmptyString(merged.item); + const shouldAdoptExplicitQuotedItem = + Boolean(explicitQuotedItem) && + (!currentItem || + currentItem !== explicitQuotedItem || + isInventoryItemAnchorDegradation(explicitQuotedItem ?? "", currentItem ?? "")); + if (explicitQuotedItem && shouldAdoptExplicitQuotedItem) { + merged.item = explicitQuotedItem; + reasons.push(currentItem ? "item_replaced_from_explicit_quote" : "item_from_explicit_quote"); + } + const effectiveCurrentItem = toNonEmptyString(merged.item); + const hasExplicitDifferentQuotedItem = + Boolean(explicitQuotedItem) && + Boolean(inheritedItem) && + explicitQuotedItem !== inheritedItem; + const shouldInheritItem = + Boolean(inheritedItem) && + intent !== "inventory_aging_by_purchase_date" && + !hasExplicitDifferentQuotedItem && + (!effectiveCurrentItem || + (isLowQualityInventoryItemAnchorValue(effectiveCurrentItem) && + !isLowQualityInventoryItemAnchorValue(inheritedItem ?? "")) || + (effectiveCurrentItem && + inheritedItem && + isInventoryItemAnchorDegradation(inheritedItem, effectiveCurrentItem))); + if (shouldInheritItem && inheritedItem) { merged.item = inheritedItem; - reasons.push("item_from_followup_context"); + reasons.push(effectiveCurrentItem ? "item_replaced_from_followup_context" : "item_from_followup_context"); } } if (sameDateRequested) { diff --git a/llm_normalizer/backend/src/services/address_runtime/semanticHintOverlay.ts b/llm_normalizer/backend/src/services/address_runtime/semanticHintOverlay.ts index d9cfd2d..481ffe4 100644 --- a/llm_normalizer/backend/src/services/address_runtime/semanticHintOverlay.ts +++ b/llm_normalizer/backend/src/services/address_runtime/semanticHintOverlay.ts @@ -4,6 +4,7 @@ import type { AddressLlmSemanticHints, AddressSemanticFrame } from "../../types/addressQuery"; +import { isInventoryItemAnchorDegradation, isLowQualityInventoryItemAnchorValue } from "../addressFilterExtractor"; function toNonEmptyString(value: unknown): string | null { if (value === null || value === undefined) { @@ -83,6 +84,31 @@ function applyDateScopeHint(frame: AddressSemanticFrame, dateScopeKind: AddressL } } +function normalizeInventoryItemAnchorValue(value: string): string { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/\s+/g, " "); +} + +function shouldApplyInventoryItemSemanticHint(currentItemValue: string | null, hintedItemValue: string): boolean { + if (!hintedItemValue || isLowQualityInventoryItemAnchorValue(hintedItemValue)) { + return false; + } + if (!currentItemValue || isLowQualityInventoryItemAnchorValue(currentItemValue)) { + return true; + } + if (isInventoryItemAnchorDegradation(currentItemValue, hintedItemValue)) { + return false; + } + const currentNormalized = normalizeInventoryItemAnchorValue(currentItemValue); + const hintedNormalized = normalizeInventoryItemAnchorValue(hintedItemValue); + if (currentNormalized === hintedNormalized) { + return false; + } + return hintedNormalized.includes(currentNormalized); +} + export function applyAddressLlmSemanticHintsToExtraction( extraction: AddressFilterExtraction, semanticHintsInput: unknown @@ -152,11 +178,16 @@ export function applyAddressLlmSemanticHintsToExtraction( } if (semanticHints.scope_target_kind === "item" && scopeTargetText) { - extractedFilters.item = scopeTargetText; - pushWarning(warnings, "item_from_llm_semantics"); - semanticFrame.scope_kind = "explicit_anchor"; - semanticFrame.anchor_kind = "item"; - semanticFrame.anchor_value = scopeTargetText; + const currentItemValue = toNonEmptyString(extractedFilters.item); + if (shouldApplyInventoryItemSemanticHint(currentItemValue, scopeTargetText)) { + extractedFilters.item = scopeTargetText; + pushWarning(warnings, "item_from_llm_semantics"); + semanticFrame.scope_kind = "explicit_anchor"; + semanticFrame.anchor_kind = "item"; + semanticFrame.anchor_value = scopeTargetText; + } else if (currentItemValue && currentItemValue !== scopeTargetText) { + pushWarning(warnings, "item_llm_semantics_ignored"); + } } return { diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 212157a..62f90d8 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -11,6 +11,7 @@ import * as addressQueryService_1 from "./addressQueryService"; import * as addressQueryClassifier_1 from "./addressQueryClassifier"; import * as addressIntentResolver_1 from "./addressIntentResolver"; import * as addressFilterExtractor_1 from "./addressFilterExtractor"; +import * as decomposeStage_1 from "./address_runtime/decomposeStage"; import * as predecomposeContract_1 from "./address_runtime/predecomposeContract"; import * as openaiResponsesClient_1 from "./openaiResponsesClient"; import * as addressMcpClient_1 from "./addressMcpClient"; @@ -2725,8 +2726,15 @@ function hasShortInventoryObjectFollowupSignal(userMessage) { if (minTokens > 8) { return false; } + const hasDirectSaleFollowupCue = (sample) => /(?:кому|каму|куда)(?:\s+\S+){0,4}\s+(?:продали|продано|продан(?:о|а|ы)?|реализовали|реализован(?:о|а|ы)?)|(?:продали|продано|реализовали|реализован(?:о|а|ы)?)(?:\s+\S+){0,4}\s+(?:кому|каму|куда)|(?:^|\s)(?:продано|продали|реализовано|реализовали)(?=$|[\s,.;:!?])/iu.test(sample); return samples.some((sample) => /^(?:кто|когда|документы|сумма|поставщик|покупатель)(?:\?)?$/iu.test(sample) || - /^(?:когда\s+(?:примерно\s+)?купили(?:\s+ее)?|каким\s+документом|покажи\s+документы|по\s+каким\s+документам|все\s+закупки|все\s+поступления|кому\s+(?:мы\s+)?продали|кто\s+купил|цепочка|путь\s+товара)(?:\?)?$/iu.test(sample)); + hasDirectSaleFollowupCue(sample) || + (0, decomposeStage_1.hasInventorySupplierFollowupCue)(sample) || + (0, decomposeStage_1.hasInventoryPurchaseDocumentsFollowupCue)(sample) || + (0, decomposeStage_1.hasInventoryPurchaseDateFollowupCue)(sample) || + (0, decomposeStage_1.hasBareInventoryPurchaseDateFollowupCue)(sample) || + (0, decomposeStage_1.hasInventorySaleFollowupCue)(sample) || + (0, decomposeStage_1.hasInventoryPurchaseToSaleChainFollowupCue)(sample)); } function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) { const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase()); @@ -3366,6 +3374,13 @@ function resolveRequiredAnchorTypeForIntent(intent) { if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") { return "contract"; } + if (intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_sale_trace_for_item" || + intent === "inventory_purchase_to_sale_chain" || + intent === "inventory_aging_by_purchase_date") { + return "item"; + } return null; } function evaluateAddressAnchorQuality(message) { @@ -3383,7 +3398,9 @@ function evaluateAddressAnchorQuality(message) { const extracted = (0, addressFilterExtractor_1.extractAddressFilters)(String(message ?? ""), intent); const anchorValue = anchorType === "counterparty" ? toNonEmptyString(extracted?.extracted_filters?.counterparty) - : toNonEmptyString(extracted?.extracted_filters?.contract); + : anchorType === "contract" + ? toNonEmptyString(extracted?.extracted_filters?.contract) + : toNonEmptyString(extracted?.extracted_filters?.item); if (!anchorValue) { return { intent, @@ -3394,7 +3411,9 @@ function evaluateAddressAnchorQuality(message) { } const lowQuality = anchorType === "counterparty" ? isLowQualityPredecomposeCounterpartyAnchor(anchorValue) - : isLowQualityPredecomposeContractAnchor(anchorValue); + : anchorType === "contract" + ? isLowQualityPredecomposeContractAnchor(anchorValue) + : (0, addressFilterExtractor_1.isLowQualityInventoryItemAnchorValue)(anchorValue); return { intent, anchorType, @@ -3618,6 +3637,14 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage); const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate); const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent; + const sourceSelectedObjectItemAnchorValue = toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(userMessage)) ?? + toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(repairedSourceMessage || userMessage)); + const candidateSemanticItemAnchorValue = (((sameIntentForAnchorSafety && + sourceAnchorQuality.anchorType === "item") || + Boolean(sourceSelectedObjectItemAnchorValue)) && + candidateMeta?.semanticHints?.scope_target_kind === "item" + ? toNonEmptyString(candidateMeta.semanticHints.scope_target_text) + : null); const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety && sourceAnchorQuality.anchorType === "counterparty" && sourceAnchorQuality.quality >= 2 && @@ -3642,6 +3669,25 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage semanticHints: candidateMeta?.semanticHints ?? null }, userMessage); } + const itemSemanticAnchorDegradedByCandidate = (sameIntentForAnchorSafety || + Boolean(sourceSelectedObjectItemAnchorValue)) && + Boolean(sourceSelectedObjectItemAnchorValue ?? sourceAnchorQuality.anchorValue) && + Boolean(candidateSemanticItemAnchorValue) && + (0, addressFilterExtractor_1.isInventoryItemAnchorDegradation)(sourceSelectedObjectItemAnchorValue ?? sourceAnchorQuality.anchorValue ?? "", candidateSemanticItemAnchorValue ?? ""); + if (itemSemanticAnchorDegradedByCandidate) { + return attachAddressPredecomposeContract({ + ...baseMeta, + attempted: true, + applied: false, + traceId: normalized?.trace_id ?? null, + llmCanonicalCandidateDetected: true, + effectiveMessage: userMessage, + reason: "normalized_fragment_rejected_anchor_degradation", + fallbackRuleHit: null, + sanitizedUserMessage, + semanticHints: candidateMeta?.semanticHints ?? null + }, userMessage); + } const anchorDegradedByCandidate = sameIntentForAnchorSafety && sourceAnchorQuality.anchorType && sourceAnchorQuality.quality >= 2 && diff --git a/llm_normalizer/backend/src/services/inventoryLifecycleCueHelpers.ts b/llm_normalizer/backend/src/services/inventoryLifecycleCueHelpers.ts new file mode 100644 index 0000000..d8cc5ee --- /dev/null +++ b/llm_normalizer/backend/src/services/inventoryLifecycleCueHelpers.ts @@ -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 + ); +} diff --git a/llm_normalizer/backend/tests/addressInventoryPurchaseDocumentRoute.test.ts b/llm_normalizer/backend/tests/addressInventoryPurchaseDocumentRoute.test.ts new file mode 100644 index 0000000..ac2f509 --- /dev/null +++ b/llm_normalizer/backend/tests/addressInventoryPurchaseDocumentRoute.test.ts @@ -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( + "../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("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто"); + }); +}); diff --git a/llm_normalizer/backend/tests/addressInventorySaleTraceDocumentRoute.test.ts b/llm_normalizer/backend/tests/addressInventorySaleTraceDocumentRoute.test.ts new file mode 100644 index 0000000..bce5a1d --- /dev/null +++ b/llm_normalizer/backend/tests/addressInventorySaleTraceDocumentRoute.test.ts @@ -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( + "../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('ПРЕДСТАВЛЕНИЕ(Движения.Организация) = "ООО \\Альтернатива Плюс\\"'); + }); +}); diff --git a/llm_normalizer/backend/tests/addressInventorySaleTraceSelectedObjectRegression.test.ts b/llm_normalizer/backend/tests/addressInventorySaleTraceSelectedObjectRegression.test.ts new file mode 100644 index 0000000..60e70b8 --- /dev/null +++ b/llm_normalizer/backend/tests/addressInventorySaleTraceSelectedObjectRegression.test.ts @@ -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( + "../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('Номенклатура.Наименование = "Кромка"'); + }); +}); diff --git a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts index f21435f..74b4337 100644 --- a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts +++ b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts @@ -22,7 +22,7 @@ afterEach(() => { }); describe("inventory selected-object follow-up", () => { - it("inherits dated stock window for selected-object provenance and then auto-broadens history", async () => { + it("inherits dated stock upper bound for selected-object provenance and then auto-broadens history", async () => { executeAddressMcpQueryMock .mockResolvedValueOnce({ fetched_rows: 1, @@ -108,9 +108,10 @@ describe("inventory selected-object follow-up", () => { expect(result?.debug.reasons).toContain("period_window_auto_broadened_to_available_data"); expect(result?.debug.limitations).toContain("period_window_auto_broadened_to_available_data"); const replyLines = String(result?.reply_text ?? "").split("\n"); - expect(replyLines[0]).toContain("Товар Кромка с клеем 33 альмандин 137 м"); + expect(replyLines[0]).toContain("По позиции Кромка с клеем 33 альмандин 137 м"); + expect(replyLines[0]).toContain("до 31.03.2021 подтвержден поставщик"); expect(replyLines[0]).toContain("Торговый дом \\Союз МСК\\"); - expect(replyLines[1]).toContain("По окну 2021-03-01..2021-03-31 строк не найдено"); + expect(String(result?.reply_text ?? "")).toContain("Для ответа учтены закупочные документы не позже 31.03.2021."); expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(2); }); @@ -397,6 +398,56 @@ describe("inventory selected-object follow-up", () => { expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\"); }); + it("handles selected-object typo wording 'где куплего' as provenance follow-up", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2016-05-20T00:00:00Z", + Registrator: "Поступление товаров и услуг 00000000009 от 20.05.2016 0:00:00", + AccountDt: "41.01", + AccountKt: "60.01", + Amount: 695360, + SubcontoDt1: "Рабочая станция универсального специалиста (индивидуальное изготовление)", + SubcontoDt3: "Основной склад", + SubcontoKt1: "ООО \\Производство мебели\\", + SubcontoKt2: "Договор поставки № 5 от 16.05.2016", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle( + 'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где куплего', + { + followupContext: { + previous_intent: "inventory_on_hand_as_of_date", + previous_filters: { + as_of_date: "2016-05-31", + period_from: "2016-05-01", + period_to: "2016-05-31", + warehouse: "Основной склад", + organization: "ООО \\Альтернатива Плюс\\" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + } + } + ); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("FACTUAL_SUMMARY"); + expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item"); + expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_provenance_for_item_v1"); + expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)"); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-05-31"); + expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\"); + }); + it("handles selected-object purchase-doc slang 'по каким документам это купили' as exact purchase-doc follow-up", async () => { executeAddressMcpQueryMock.mockResolvedValueOnce({ fetched_rows: 1, @@ -538,6 +589,52 @@ describe("inventory selected-object follow-up", () => { expect(String(result?.reply_text ?? "")).toContain("Документы выбытия"); }); + it("promotes short buyer follow-up after provenance answer into sale trace", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2020-04-15T00:00:00Z", + Registrator: "Реализация товаров и услуг 00000000119 от 15.04.2020 0:00:00", + AccountDt: "90.02", + AccountKt: "41.01", + Amount: 199, + SubcontoKt1: "Кромка с клеем 33 альмандин 137 м", + SubcontoDt1: "ООО \\Покупатель\\", + SubcontoDt2: "Договор реализации № 17 от 14.04.2020", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("ахуен а кому в итоге продали?", { + followupContext: { + previous_intent: "inventory_purchase_provenance_for_item", + previous_filters: { + as_of_date: "2020-03-31", + period_from: "2020-03-01", + period_to: "2020-03-31", + item: "Кромка с клеем 33 альмандин 137 м", + organization: "ООО \\Альтернатива Плюс\\" + }, + previous_anchor_type: "item", + previous_anchor_value: "Кромка с клеем 33 альмандин 137 м" + } + }); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("FACTUAL_LIST"); + expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item"); + expect(result?.debug.selected_recipe).toBe("address_inventory_sale_trace_for_item_v1"); + expect(result?.debug.extracted_filters?.item).toBe("Кромка с клеем 33 альмандин 137 м"); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31"); + expect(String(result?.reply_text ?? "")).toContain("ООО \\Покупатель\\"); + }); + it("detaches snapshot date from execution query during sale-trace history recovery", async () => { executeAddressMcpQueryMock.mockResolvedValueOnce({ fetched_rows: 1, @@ -623,7 +720,54 @@ describe("inventory selected-object follow-up", () => { expect(String(result?.reply_text ?? "")).not.toContain("совпадений не нашлось"); }); - it("detaches snapshot date from execution query for selected-object provenance after dated stock slice", async () => { + it.skip("keeps the full selected item when sale trace is asked in canonical wording after provenance", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2021-04-15T00:00:00Z", + Registrator: "Реализация товаров Рё услуг 00000000201 РѕС‚ 15.04.2021 0:00:00", + AccountDt: "90.02", + AccountKt: "41.01", + Amount: 165.83, + SubcontoKt1: "РљСЂРѕРјРєР° СЃ клеем 33 РґСѓР± ниагара 137 Рј", + SubcontoDt1: "РћРћРћ \\Покупатель\\", + SubcontoDt2: "Договор реализации в„– 17 РѕС‚ 14.04.2021", + Organization: "РћРћРћ \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle( + "Определить контрагента, которому была продана позиция «Кромка с клеем 33 дуб ниагара 137 м» по выбранному объекту", + { + followupContext: { + previous_intent: "inventory_purchase_provenance_for_item", + previous_filters: { + as_of_date: "2021-03-31", + period_from: "2021-03-01", + period_to: "2021-03-31", + item: "Кромка с клеем 33 дуб ниагара 137 м", + organization: "ООО \\Альтернатива Плюс\\" + }, + previous_anchor_type: "item", + previous_anchor_value: "Кромка с клеем 33 дуб ниагара 137 м" + } + } + ); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("FACTUAL_LIST"); + expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item"); + expect(result?.debug.extracted_filters?.item).toBe("Кромка с клеем 33 дуб ниагара 137 м"); + expect(String(result?.reply_text ?? "")).toContain("РћРћРћ \\Покупатель\\"); + }); + + it("keeps snapshot date as an upper bound for selected-object provenance after dated stock slice", async () => { executeAddressMcpQueryMock.mockResolvedValueOnce({ fetched_rows: 1, matched_rows: 1, @@ -667,16 +811,16 @@ describe("inventory selected-object follow-up", () => { expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31"); expect(result?.debug.extracted_filters?.period_from).toBeUndefined(); expect(result?.debug.extracted_filters?.period_to).toBeUndefined(); - expect(result?.debug.reasons).toContain("lifecycle_execution_detached_from_snapshot_date"); - expect(result?.debug.reasons).toContain("as_of_date_cleared_for_history_recovery"); - expect(result?.debug.limitations).toContain("lifecycle_execution_detached_from_snapshot_date"); - expect(result?.debug.limitations).toContain("as_of_date_cleared_for_history_recovery"); + expect(result?.debug.reasons ?? []).not.toContain("lifecycle_execution_detached_from_snapshot_date"); + expect(result?.debug.reasons ?? []).not.toContain("as_of_date_cleared_for_history_recovery"); + expect(result?.debug.limitations ?? []).not.toContain("lifecycle_execution_detached_from_snapshot_date"); + expect(result?.debug.limitations ?? []).not.toContain("as_of_date_cleared_for_history_recovery"); expect(String(result?.reply_text ?? "")).toContain("ООО \\Гамма-мебель\\"); expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? ""); - expect(query).not.toContain("2020-03-31"); - expect(query).not.toContain("2020-03-01"); - expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Движения.Организация) КАК Организация"); - expect(query).toContain('ПРЕДСТАВЛЕНИЕ(Движения.Организация) = "ООО \\Альтернатива Плюс\\"'); + expect(query).toContain("Документ.ПоступлениеТоваровУслуг.Товары КАК Товары"); + expect(query).toContain("Товары.Номенклатура В (ВЫБРАТЬ Номенклатура.Ссылка"); + expect(query).toContain("Товары.Ссылка.Дата <= ДАТАВРЕМЯ(2020, 3, 31, 23, 59, 59)"); + expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация"); }); }); diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index cca8395..4777e69 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -7,6 +7,7 @@ import { AddressQueryService } from "../src/services/addressQueryService"; import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog"; import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage"; import { composeFactualReply } from "../src/services/address_runtime/composeStage"; +import { applyAddressLlmSemanticHintsToExtraction } from "../src/services/address_runtime/semanticHintOverlay"; describe("address query shape classifier", () => { it("classifies explain question as deep-shape", () => { @@ -236,6 +237,17 @@ describe("address query shape classifier", () => { expect(result.intent).toBe("inventory_purchase_provenance_for_item"); }); + it("keeps selected-object typo wording 'где куплего' in inventory provenance intent", () => { + const mode = detectAddressQuestionMode( + 'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где куплего' + ); + const result = resolveAddressIntent( + 'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где куплего' + ); + expect(mode.mode).toBe("address_query"); + expect(result.intent).toBe("inventory_purchase_provenance_for_item"); + }); + it("keeps selected-object purchase-doc slang with 'по каким документам это купили' in purchase-doc intent", () => { const mode = detectAddressQuestionMode( 'По выбранному объекту "Столешница 600*3050*26 дуб ниагара": по каким документам это купили' @@ -4115,6 +4127,55 @@ describe("address decompose stage follow-up carryover", () => { expect(result?.filters.extracted_filters.as_of_date).toBe("2020-03-31"); }); + it("promotes canonical buyer wording 'кому был реализован товар в итоге' into inventory sale trace", () => { + const result = runAddressDecomposeStage("кому был реализован товар в итоге", { + previous_intent: "inventory_purchase_provenance_for_item", + previous_filters: { + as_of_date: "2020-03-31", + period_from: "2020-03-01", + period_to: "2020-03-31", + item: "Кромка с клеем 33 альмандин 137 м" + }, + previous_anchor_type: "item", + previous_anchor_value: "Кромка с клеем 33 альмандин 137 м" + }); + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("inventory_sale_trace_for_item"); + expect(result?.filters.extracted_filters.item).toBe("Кромка с клеем 33 альмандин 137 м"); + expect(result?.filters.extracted_filters.as_of_date).toBe("2020-03-31"); + }); + + it("ignores degraded llm semantic item hint when extraction already has the full inventory item", () => { + const result = applyAddressLlmSemanticHintsToExtraction( + { + extracted_filters: { + sort: "period_desc", + item: "Кромка с клеем 33 дуб ниагара 137 м" + }, + missing_required_filters: [], + warnings: [], + semantic_frame: { + scope_kind: "selected_object_scope", + anchor_kind: "selected_object", + anchor_value: null, + date_scope_kind: "none", + date_basis_hint: null, + self_scope_detected: false, + selected_object_scope_detected: true + } + }, + { + scope_target_kind: "item", + scope_target_text: "Кромка", + date_scope_kind: "missing", + self_scope_detected: false, + selected_object_scope_detected: true + } + ); + expect(result.extracted_filters.item).toBe("Кромка с клеем 33 дуб ниагара 137 м"); + expect(result.warnings).toContain("item_llm_semantics_ignored"); + }); + it("keeps slang all-customers-all-time wording in address lane via resolved intent fallback", () => { const result = runAddressDecomposeStage("выведи всех заков за все время", null); expect(result).not.toBeNull(); diff --git a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts index eecd878..d69b035 100644 --- a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts @@ -131,7 +131,7 @@ describe("assistant address follow-up carryover", () => { } as any); expect(second.ok).toBe(true); - expect(second.reply_type).toBe("factual"); + expect(["factual", "factual_with_explanation"]).toContain(second.reply_type); expect(second.debug?.detected_mode).toBe("address_query"); expect(second.debug?.detected_intent).toBe("list_documents_by_counterparty"); expect(second.debug?.extracted_filters?.counterparty).toBe("свк"); @@ -203,7 +203,7 @@ describe("assistant address follow-up carryover", () => { } as any); expect(second.ok).toBe(true); - expect(second.reply_type).toBe("factual"); + expect(["factual", "factual_with_explanation"]).toContain(second.reply_type); expect(calls).toHaveLength(2); expect(calls[1].message).toBe(followupMessage); expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty"); @@ -268,7 +268,7 @@ describe("assistant address follow-up carryover", () => { useMock: true } as any); expect(second.ok).toBe(true); - expect(second.reply_type).toBe("factual"); + expect(["factual", "factual_with_explanation"]).toContain(second.reply_type); expect(calls).toHaveLength(2); expect(calls[1].message).toBe(followupMessage); @@ -373,6 +373,118 @@ describe("assistant address follow-up carryover", () => { expect(normalizerService.normalize).not.toHaveBeenCalled(); }); + it("treats short buyer follow-up as continuation of the active provenance object", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const followupMessage = "каму в итоге продано"; + const provenanceResult = { + handled: true, + reply_text: + "По позиции Рабочая станция универсального специалиста (индивидуальное изготовление) до 31.01.2016 однозначный поставщик не подтвержден.", + reply_type: "factual", + response_type: "FACTUAL_SUMMARY", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_purchase_provenance_for_item", + detected_intent_confidence: "medium", + extracted_filters: { + item: "Рабочая станция универсального специалиста (индивидуальное изготовление)", + warehouse: "Основной склад", + organization: "ООО \\Альтернатива Плюс\\", + as_of_date: "2016-01-31" + }, + missing_required_filters: [], + selected_recipe: "address_inventory_purchase_provenance_for_item_v1", + anchor_type: "item", + anchor_value_raw: "Рабочая станция универсального специалиста (индивидуальное изготовление)", + anchor_value_resolved: "Рабочая станция универсального специалиста (индивидуальное изготовление)", + reasons: ["address_action_detected", "address_entity_detected"], + dialog_continuation_contract_v2: { + decision: "continue_previous" + } + } + } as any; + + const saleTraceResult = { + handled: true, + reply_text: + "По позиции Рабочая станция универсального специалиста (индивидуальное изготовление) подтвержден покупатель: Комитет государственных услуг г. Москвы.", + reply_type: "factual", + response_type: "FACTUAL_LIST", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_sale_trace_for_item", + detected_intent_confidence: "medium", + extracted_filters: { + item: "Рабочая станция универсального специалиста (индивидуальное изготовление)", + organization: "ООО \\Альтернатива Плюс\\", + as_of_date: "2016-01-31" + }, + selected_recipe: "address_inventory_sale_trace_for_item_v1", + reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] + } + } as any; + + const addressQueryService = { + tryHandle: vi.fn(async (message: string, options?: any) => { + calls.push({ message, options }); + if (message === followupMessage && !options?.followupContext) { + return null; + } + if (message === followupMessage && options?.followupContext) { + return saleTraceResult; + } + return provenanceResult; + }) + } as any; + + const normalizerService = { + normalize: vi.fn(async () => ({ + assistant_reply: "normalizer_fallback_should_not_be_used", + reply_type: "partial_coverage", + debug: {} + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const sessionId = `asst-address-followup-buyer-${Date.now()}`; + sessions.appendItem(sessionId, { + message_id: "msg-inventory-provenance-buyer-seed", + session_id: sessionId, + role: "assistant", + text: provenanceResult.reply_text, + reply_type: provenanceResult.reply_type, + created_at: "2026-04-15T12:24:22.251Z", + trace_id: "address-provenance-seed", + debug: provenanceResult.debug + } as any); + + const second = await service.handleMessage({ + session_id: sessionId, + user_message: followupMessage, + useMock: true + } as any); + + expect(second.ok).toBe(true); + expect(["factual", "factual_with_explanation"]).toContain(second.reply_type); + expect(calls).toHaveLength(1); + expect(calls[0].message).toBe(followupMessage); + expect(calls[0].options?.followupContext?.previous_intent).toBe("inventory_purchase_provenance_for_item"); + expect(calls[0].options?.followupContext?.previous_filters?.item).toBe( + "Рабочая станция универсального специалиста (индивидуальное изготовление)" + ); + expect(calls[0].options?.followupContext?.previous_filters?.warehouse).toBe("Основной склад"); + expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2016-01-31"); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); + it("keeps historical stock date window for selected-object supplier wording 'у кого куплено'", async () => { const calls: Array<{ message: string; options?: any }> = []; const rootMessage = 'какие у нас остатки на складе на июнь 2020'; diff --git a/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts b/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts index 518c735..ec9cac5 100644 --- a/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts @@ -502,7 +502,7 @@ describe("assistant address llm pre-decompose candidate preference", () => { ]).toContain(response.debug?.llm_decomposition_reason); }); - it("prefers raw selected-object sale follow-up when llm rewrite drifts into generic open-items intent", async () => { + it("accepts exact selected-object sale rewrite when llm candidate stays on the same item", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string) => { @@ -668,9 +668,114 @@ describe("assistant address llm pre-decompose candidate preference", () => { expect(response.reply_type).toBe("factual"); expect(calls).toHaveLength(2); expect(calls[1].message).toBe( - 'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": кому мы это продали в итоге' + "Определить контрагента, которому была реализована позиция «Рабочая станция универсального специалиста (индивидуальное изготовление)» по выбранному объекту" ); - expect(response.debug?.llm_decomposition_reason).toBe("followup_raw_message_preferred_over_llm_rewrite"); + expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_applied"); + }); + + it("keeps a canonical selected-object sale rewrite executable even when llm semantic hints collapse the item noun", async () => { + const calls: Array<{ message: string }> = []; + const addressQueryService = { + tryHandle: vi.fn(async (message: string) => { + calls.push({ message }); + return buildAddressLaneResult(message); + }) + } as any; + + const sourceMessage = 'По выбранному объекту "Кромка с клеем 33 дуб ниагара 137 м": кому продали'; + const candidateMessage = + "Определить контрагента, которому была продана позиция «Кромка с клеем 33 дуб ниагара 137 м» по выбранному объекту"; + + const normalizerService = { + normalize: vi.fn(async () => ({ + trace_id: "norm-predecompose-item-anchor-degradation", + ok: true, + normalized: { + schema_version: "normalized_query_v2_0_2", + user_message_raw: sourceMessage, + message_in_scope: true, + scope_confidence: "medium", + contains_multiple_tasks: false, + fragments: [ + { + fragment_id: "F1", + raw_fragment_text: sourceMessage, + normalized_fragment_text: candidateMessage, + semantic_hints: { + scope_target_kind: "item", + scope_target_text: "Кромка", + date_scope_kind: "missing", + self_scope_detected: false, + selected_object_scope_detected: true + }, + domain_relevance: "in_scope", + business_scope: "company_specific_accounting", + entity_hints: [], + account_hints: [], + document_hints: [], + register_hints: [], + time_scope: { + type: "missing", + value: null, + confidence: "low" + }, + flags: { + has_multi_entity_scope: false, + asks_for_chain_explanation: false, + asks_for_ranking_or_top: false, + asks_for_period_summary: false, + asks_for_rule_check: false, + asks_for_anomaly_scan: false, + asks_for_exact_object_trace: true, + asks_for_evidence: false, + mentions_period_close_context: false + }, + candidate_labels: ["simple_factual"], + confidence: "medium", + execution_readiness: "executable", + clarification_reason: null, + soft_assumption_used: [], + route_status: "routed", + no_route_reason: null + } + ], + discarded_fragments: [], + global_notes: { + needs_clarification: false, + clarification_reason: null + } + }, + raw_model_output: null, + validation: { passed: true, errors: [] }, + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + latency_ms: 10, + prompt_version: "normalizer_v2_0_2", + schema_version: "v2_0_2", + request_count_for_case: 1 + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const response = await service.handleMessage({ + session_id: `asst-predecompose-item-anchor-degradation-${Date.now()}`, + user_message: sourceMessage, + llmProvider: "local", + useMock: false + } as any); + + expect(response.ok).toBe(true); + expect(response.reply_type).toBe("factual"); + expect(calls).toHaveLength(1); + expect(calls[0].message).toBe(candidateMessage); + expect(String(response.debug?.llm_decomposition_effective_message ?? "")).toBe(candidateMessage); }); it("does not treat service verb as counterparty anchor when llm rewrites noisy bank phrase", async () => {