diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 02e4074..67e27e0 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); +exports.isLowQualityCounterpartyAnchorValue = isLowQualityCounterpartyAnchorValue; exports.isLowQualityInventoryItemAnchorValue = isLowQualityInventoryItemAnchorValue; exports.isInventoryItemAnchorDegradation = isInventoryItemAnchorDegradation; exports.extractSelectedObjectQuotedValue = extractSelectedObjectQuotedValue; @@ -642,13 +643,17 @@ function isLowQualityCounterpartyAnchorValue(rawValue) { const value = String(rawValue ?? "") .trim() .toLowerCase() - .replace(/ё/g, "е"); + .replace(/ё/g, "е") + .replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ""); if (!value) { return true; } if (/(?:за\s+вс[её]\s+время|за\s+всю\s+истори(?:ю|и)|all\s+time|entire\s+period|full\s+history)/iu.test(value)) { return true; } + if (/^(?:товар(?:ы|а|ов|у|ом|ами|ах)?|номенклатур(?:а|ы|у|ой|ами)?|позици(?:я|и|ю|ей)|остат(?:ок|ки|ка|ков|кам|ками)?|склад|складе|складу)$/iu.test(value)) { + return true; + } if (/^(?:или|это|там|может|можно|обычн\p{L}*|клиентск\p{L}*|банковск\p{L}*(?:\/|\s+и\s+)?финансов\p{L}*)\b/iu.test(value)) { return true; } diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index ce9c345..f9734e4 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -1,6 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.AddressQueryService = void 0; +exports.ensureInventorySupplierOverlapLeadLine = ensureInventorySupplierOverlapLeadLine; const config_1 = require("../config"); const addressRecipeCatalog_1 = require("./addressRecipeCatalog"); const addressMcpClient_1 = require("./addressMcpClient"); @@ -1988,6 +1989,10 @@ function hasExplicitPeriodWindow(filters) { function asksForUnresolvedInventorySupplierLink(userMessage) { return /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(String(userMessage ?? "")); } +function messagePairAsksForUnresolvedInventorySupplierLink(userMessage, rawUserMessage) { + return (asksForUnresolvedInventorySupplierLink(userMessage) || + asksForUnresolvedInventorySupplierLink(rawUserMessage)); +} function canAutoBroadenPeriodWindow(intent, filters) { if (Array.isArray(filters.warnings) && filters.warnings?.includes("exact_historical_period_window_requested")) { return false; @@ -2037,7 +2042,11 @@ function shouldDetachLifecycleExecutionFromSnapshotContext(intent, reasons) { intent !== "inventory_purchase_to_sale_chain") { return false; } - return (reasons.includes("period_window_semantic_from_inventory_snapshot_context") || + const historyIntent = intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || + intent === "inventory_purchase_to_sale_chain"; + return ((historyIntent && reasons.includes("as_of_date_from_analysis_context")) || + reasons.includes("period_window_semantic_from_inventory_snapshot_context") || reasons.includes("period_window_semantic_from_inventory_as_of_month") || reasons.includes("as_of_date_from_followup_context") || reasons.includes("period_from_followup_context") || @@ -2119,6 +2128,75 @@ function injectNoticeAfterLeadLine(text, notice) { } return [lines[0], normalizedNotice, ...lines.slice(1)].join("\n"); } +function injectNoticeBeforeLeadLine(text, notice) { + const normalizedText = typeof text === "string" ? text : ""; + const normalizedNotice = typeof notice === "string" ? notice.trim() : ""; + if (!normalizedText.trim()) { + return normalizedNotice; + } + if (!normalizedNotice) { + return normalizedText; + } + return [normalizedNotice, normalizedText].join("\n"); +} +function injectAutoBroadenedPeriodNotice(intent, text, notice) { + if (intent === "inventory_supplier_stock_overlap_as_of_date") { + return injectNoticeBeforeLeadLine(text, notice); + } + return injectNoticeAfterLeadLine(text, notice); +} +function extractSupplierOverlapReplyCounterparties(text) { + const counterparties = []; + const pattern = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|\n]+)/giu; + for (const match of String(text ?? "").matchAll(pattern)) { + const value = String(match[1] ?? "").replace(/\s+/gu, " ").trim(); + if (!value || /^(?:\u043d\u0435\s+\u0432\u044b\u0434\u0435\u043b\u0435\u043d|\u043d\/\u0434|unknown)$/iu.test(value)) { + continue; + } + counterparties.push(value); + } + return uniqueStrings(counterparties); +} +function formatSupplierOverlapCounterpartyLead(counterparties, limit = 6) { + const visible = counterparties.slice(0, limit); + const remaining = counterparties.length - visible.length; + const suffix = remaining > 0 ? `; и еще ${remaining}` : ""; + return `${visible.join("; ")}${suffix}`; +} +function hasInventorySupplierOverlapDefensiveLead(firstLine) { + const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435"); + const startsWithStockScope = /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test(normalized); + const hasDefensiveAttribution = /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test(normalized); + return startsWithStockScope && hasDefensiveAttribution; +} +function ensureInventorySupplierOverlapLeadLine(intent, text) { + if (intent !== "inventory_supplier_stock_overlap_as_of_date") { + return text; + } + const normalizedText = typeof text === "string" ? text : ""; + const lines = normalizedText.split(/\r?\n/u); + const firstLine = lines[0]?.trim() ?? ""; + const displayedCounterparties = extractSupplierOverlapReplyCounterparties(normalizedText); + if (displayedCounterparties.length > 0 && + hasInventorySupplierOverlapDefensiveLead(firstLine)) { + const repairedLead = displayedCounterparties.length === 1 + ? `По складскому остатку найден поставщик закупочного следа: ${displayedCounterparties[0]}.` + : `По складскому остатку найдено несколько поставщиков закупочного следа: ${formatSupplierOverlapCounterpartyLead(displayedCounterparties)}.`; + return [repairedLead, firstLine, ...lines.slice(1)].join("\n"); + } + if (!normalizedText.trim() || + /^По\s+(?:складскому\s+остатку|окну)\b/iu.test(firstLine) || + /^В\s+текущем\s+складском\s+срезе\b/iu.test(firstLine)) { + return normalizedText; + } + if (!/^(?:Что\s+проверили:|Часть\s+закупочных\s+операций|Опорные\s+документы:)/iu.test(firstLine)) { + return normalizedText; + } + return [ + "По складскому остатку однозначный поставщик текущего остатка не подтвержден; ниже показан проверенный закупочный след и ограничения.", + normalizedText + ].join("\n"); +} function runtimeReadinessForLimitedCategory(category) { if (category === "empty_match" || category === "missing_anchor") { return "LIVE_QUERYABLE_WITH_LIMITS"; @@ -2971,7 +3049,7 @@ class AddressQueryService { if (intent.intent === "inventory_supplier_stock_overlap_as_of_date" && !toNonEmptyFilterValue(filters.extracted_filters.period_from) && !toNonEmptyFilterValue(filters.extracted_filters.period_to) && - asksForUnresolvedInventorySupplierLink(userMessage) && + messagePairAsksForUnresolvedInventorySupplierLink(userMessage, options.rawUserMessage) && !/(?:Р·Р°\s+РІСЃ[её]\s+время|Р·Р°\s+любой\s+период|all[\s-]?time|all\s+periods?)/iu.test(userMessage)) { const monthWindow = deriveMonthWindowForDate(filters.extracted_filters.as_of_date); if (monthWindow) { @@ -3079,6 +3157,19 @@ class AddressQueryService { receivablesConfirmedExecution?.executionFilters ?? vatPayableConfirmedExecution?.executionFilters ?? filters.extracted_filters; + if (intent.intent === "inventory_aging_by_purchase_date" && + toNonEmptyFilterValue(executionFilters.as_of_date) && + (toNonEmptyFilterValue(executionFilters.period_from) || toNonEmptyFilterValue(executionFilters.period_to))) { + executionFilters = { ...executionFilters }; + delete executionFilters.period_from; + delete executionFilters.period_to; + if (!filters.warnings.includes("period_window_detached_for_inventory_aging_execution")) { + filters.warnings.push("period_window_detached_for_inventory_aging_execution"); + } + if (!baseReasons.includes("period_window_detached_for_inventory_aging_execution")) { + baseReasons.push("period_window_detached_for_inventory_aging_execution"); + } + } if (intent.intent === "counterparty_activity_lifecycle" && typeof executionFilters.counterparty === "string" && sameOrganizationEntityReference(executionFilters.counterparty, executionFilters.organization ?? activeOrganization)) { @@ -3233,6 +3324,7 @@ class AddressQueryService { : typeof filterSet.account === "string" ? filterSet.account : undefined, + warehouseHint: typeof filterSet.warehouse === "string" ? filterSet.warehouse : undefined, periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined, periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined, asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined, @@ -3861,7 +3953,7 @@ class AddressQueryService { }); return { handled: true, - reply_text: repairCounterpartyReplyLabel(replyText, intent.intent, filters.extracted_filters), + reply_text: repairCounterpartyReplyLabel(ensureInventorySupplierOverlapLeadLine(intent.intent, replyText), intent.intent, filters.extracted_filters), reply_type: (0, composeStage_1.inferReplyType)(responseType), response_type: responseType, debug: debugPayload @@ -3983,7 +4075,7 @@ class AddressQueryService { }); return { handled: true, - reply_text: repairCounterpartyReplyLabel(input.replyText, intent.intent, (input.extractedFilters ?? filters.extracted_filters)), + reply_text: repairCounterpartyReplyLabel(ensureInventorySupplierOverlapLeadLine(intent.intent, input.replyText), intent.intent, (input.extractedFilters ?? filters.extracted_filters)), reply_type: (0, composeStage_1.inferReplyType)(input.responseType), response_type: input.responseType, debug: debugPayload @@ -4263,7 +4355,7 @@ class AddressQueryService { resultMode: broadenedCoverageEvidence.result_mode ?? undefined }); return buildFactualExecutionResult({ - replyText: injectNoticeAfterLeadLine(broadenedFactual.text, broadenedPrefix), + replyText: injectAutoBroadenedPeriodNotice(intent.intent, broadenedFactual.text, broadenedPrefix), responseType: broadenedFactual.responseType, responseSemantics: broadenedFactual.semantics, selectedRecipe: broadenedSelection.selected_recipe.recipe_id, diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index e601670..bfcc530 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -1441,20 +1441,11 @@ function buildInventoryAgingByPurchaseDateQuery(filters, resolvedLimit) { buildInventoryItemReferenceCondition(filters, ["Остатки.Субконто1"]) ]; const onHandWhereClause = buildStaticWhereClause(onHandScopeConditions); - const currentStockItemSubquery = [ - "(ВЫБРАТЬ РАЗЛИЧНЫЕ", - " Остатки.Субконто1", - " ИЗ", - ` РегистрБухгалтерии.Хозрасчетный.Остатки(${asOfExpr}, , , ) КАК Остатки`, - buildStaticWhereClause(onHandScopeConditions, " "), - ")" - ].join("\n"); const purchaseWhereClause = buildStaticWhereClause([ "Товары.Ссылка.Проведен = ИСТИНА", `Товары.Ссылка.Дата <= ${asOfExpr}`, buildOrganizationReferenceCondition(filters, ["Товары.Ссылка.Организация"]), - buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]), - `Товары.Номенклатура В ${currentStockItemSubquery}` + buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]) ]); return ` ВЫБРАТЬ ПЕРВЫЕ ${resolvedLimit} diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index cde3176..ec7e785 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -559,6 +559,9 @@ function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters, if (!canReenterInventoryRoot) { return false; } + if (intent === "inventory_aging_by_purchase_date") { + return false; + } if (intent !== "unknown" && !isInventoryIntent(intent) && !hasInventoryRootRestatementCue) { return false; } @@ -584,13 +587,13 @@ function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters, } function hasSelectedObjectInventorySignal(text) { const repairedSelectedObjectText = textWithRepairedVariant(String(text ?? "")); - if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(repairedSelectedObjectText)) { + if (/(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(repairedSelectedObjectText)) { return true; } - if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? ""))) { + if (/(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? ""))) { return true; } - return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? "")); + return /(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? "")); } function hasSelectedObjectInlineSnapshotMetadata(text) { return /(?:дата\s+строки|строка\s+от|количество\s*:|стоимость\s*:|склад\s*:|организация\s*:|\|\s*(?:склад|количество|стоимость|организация|дата\s+строки)\s*:)/iu.test(String(text ?? "")); @@ -618,6 +621,7 @@ function hasInventorySupplierFollowupCue(text) { function hasInventoryPurchaseDocumentsFollowupCue(text) { const value = textWithRepairedVariant(String(text ?? "")); return (/(?:по\s+каким\s+документам\s+(?:это|его|ее|её|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+(?:это|его|ее|её|она|он|этот\s+товар|эта\s+позиция|эту\s+позицию)?\s*(?:подтвержден[оа]?|подтверждена\s+закупка|доказан[оа]?|закрыт[оа]?)|закупк[аи]\s+подтвержден[аы]?\s+документ|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему))/iu.test(value) || + /(?:документ[а-яё]*[\s\S]{0,40}поступлен[а-яё]*[\s\S]{0,140}(?:выбранн|товар|позици)|какие\s+документ[а-яё]*[\s\S]{0,40}поступлен[а-яё]*[\s\S]{0,140}(?:выбранн|товар|позици))/iu.test(value) || /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+(?:это|его|её|ее|она|он|этот\s+товар|эта\s+позиция|эту\s+позицию)?\s*(?:подтвержден[оа]?|доказан[оа]?|закрыт[оа]?)|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(value) || /(?:(?:покажи|показать|выведи|дай)?[\s\S]{0,30}док(?:и|умент[а-яё]*)[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}док(?:и|умент[а-яё]*))/iu.test(value)); } @@ -823,6 +827,22 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { reasons.push("item_cleared_for_stock_slice_aging"); } }; + const clearInventoryOnHandWarehouseAliasItem = () => { + if (intent !== "inventory_on_hand_as_of_date") { + return; + } + const currentItem = toNonEmptyString(merged.item); + const currentWarehouse = toNonEmptyString(merged.warehouse); + if (!currentItem || !currentWarehouse) { + return; + } + const normalizedItem = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeSearchText)(currentItem); + const normalizedWarehouse = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeSearchText)(currentWarehouse); + if (normalizedItem && normalizedWarehouse && normalizedItem === normalizedWarehouse) { + delete merged.item; + reasons.push("item_cleared_as_warehouse_scope_alias_for_inventory_snapshot"); + } + }; if (!followupContext) { if ((intent === "list_open_contracts" || intent === "open_contracts_confirmed_as_of_date" || @@ -838,17 +858,20 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { : "as_of_date_derived_from_period_for_open_contracts"); } } + clearInventoryOnHandWarehouseAliasItem(); clearInventoryAgingOrganizationAliasItem(null); return { filters: merged, reasons }; } const previous = followupContext.previous_filters ?? {}; + const root = followupContext.root_filters ?? {}; const previousAnchorValue = toNonEmptyString(followupContext.previous_anchor_value); const previousCounterparty = toNonEmptyString(previous.counterparty); const previousContract = toNonEmptyString(previous.contract); const previousAccount = toNonEmptyString(previous.account); const previousItem = toNonEmptyString(previous.item); const previousAnchorItem = followupContext.previous_anchor_type === "item" ? previousAnchorValue : null; - const previousOrganization = toNonEmptyString(previous.organization); + const previousOrganization = toNonEmptyString(previous.organization) ?? toNonEmptyString(root.organization); + const previousWarehouse = toNonEmptyString(previous.warehouse) ?? toNonEmptyString(root.warehouse); const previousAsOfDate = toNonEmptyString(previous.as_of_date); const previousPeriodFrom = toNonEmptyString(previous.period_from); const previousPeriodTo = toNonEmptyString(previous.period_to); @@ -1231,6 +1254,10 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { reasons.push("period_derived_from_inventory_root_frame_year"); } if (intent === "inventory_aging_by_purchase_date") { + if (previousWarehouse && !toNonEmptyString(merged.warehouse)) { + merged.warehouse = previousWarehouse; + reasons.push("warehouse_from_followup_context"); + } clearInventoryAgingOrganizationAliasItem(previousOrganization); } if (!sameDateRequested && @@ -1254,6 +1281,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { delete merged.period_to; reasons.push("period_cleared_by_all_time_followup"); } + clearInventoryOnHandWarehouseAliasItem(); return { filters: merged, reasons }; } if (intent === "counterparty_activity_lifecycle" && @@ -1414,6 +1442,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { reasons.push("counterparty_cleared_as_organization_scope_alias"); } } + clearInventoryOnHandWarehouseAliasItem(); return { filters: merged, reasons }; } function resolveMissingRequiredFilters(intent, filters) { @@ -1441,16 +1470,50 @@ function resolveMissingRequiredFilters(intent, filters) { }); } function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext) { + const normalizedMessage = String(userMessage ?? ""); + const hasSelectedObjectReference = hasSelectedObjectInventorySignal(normalizedMessage); if (!followupContext || (!followupContext.previous_intent && !followupContext.target_intent)) { + if (hasSelectedObjectReference && hasInventoryPurchaseDocumentsFollowupCue(normalizedMessage)) { + if (detectedIntent.intent === "unknown" || + detectedIntent.intent === "list_documents_by_counterparty" || + detectedIntent.intent === "list_documents_by_contract" || + detectedIntent.intent === "inventory_on_hand_as_of_date") { + return { + intent: "inventory_purchase_documents_for_item", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_documents"] + }; + } + } + if (hasSelectedObjectReference && hasInventorySupplierFollowupCue(normalizedMessage)) { + if (detectedIntent.intent === "unknown" || + detectedIntent.intent === "list_documents_by_counterparty" || + detectedIntent.intent === "list_documents_by_contract" || + detectedIntent.intent === "inventory_on_hand_as_of_date") { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_supplier"] + }; + } + } + if (hasSelectedObjectReference && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) { + if (detectedIntent.intent === "unknown" || detectedIntent.intent === "inventory_on_hand_as_of_date") { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_purchase_date"] + }; + } + } return detectedIntent; } - const normalizedMessage = String(userMessage ?? ""); const genericFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage); const previousFilters = followupContext.previous_filters ?? {}; const purchaseBridgeContinuationSignal = followupContext.previous_intent === "vat_liability_confirmed_for_tax_period" && Boolean(toNonEmptyString(previousFilters.purchase_date_bridge_selected)) && hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage); - const hasFollowupSignal = genericFollowupSignal || purchaseBridgeContinuationSignal; + const hasFollowupSignal = genericFollowupSignal || purchaseBridgeContinuationSignal || hasSelectedObjectReference; if (!hasFollowupSignal) { return detectedIntent; } @@ -1481,7 +1544,6 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo followupContext.root_anchor_type === "item" || followupContext.current_frame_kind === "inventory_root" || followupContext.current_frame_kind === "inventory_drilldown"; - const hasSelectedObjectReference = hasSelectedObjectInventorySignal(normalizedMessage); const inventorySelectedObjectFollowup = inventoryLineageActive && (hasSelectedObjectReference || (previousIsInventoryFamily && hasFollowupSignal)); const previousCounterpartyLaneActive = hasPreviousCounterparty && (followupContext.previous_anchor_type === "counterparty" || diff --git a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js index 1f6c89f..5f6a6d8 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js @@ -57,6 +57,53 @@ function inventoryRequestedPartyMatches(requested, actualParties) { function inventoryPartyListOrUnknown(parties) { return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем"; } +function inventoryPartyLeadList(parties, limit = 6) { + const visible = parties.slice(0, limit); + const remaining = parties.length - visible.length; + const suffix = remaining > 0 ? `; и еще ${remaining}` : ""; + return `${visible.join("; ")}${suffix}`; +} +function inventoryItemLeadList(items, limit = 6) { + const visible = items.slice(0, limit); + const remaining = items.length - visible.length; + const suffix = remaining > 0 ? `; и еще ${remaining}` : ""; + return `${visible.join("; ")}${suffix}`; +} +function collectInventoryTraceItems(rows) { + const result = []; + const seen = new Set(); + for (const row of rows) { + const item = String(row.item ?? "").trim(); + if (!item) { + continue; + } + const comparable = normalizeInventoryReplyEntityToken(item); + if (!comparable || seen.has(comparable)) { + continue; + } + seen.add(comparable); + result.push(item); + } + return result; +} +function extractInventoryCounterpartiesFromEvidenceLines(lines) { + const counterparties = []; + for (const line of lines) { + const match = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|]+)/iu.exec(line); + const value = String(match?.[1] ?? "").replace(/\s+/gu, " ").trim(); + if (value) { + counterparties.push(value); + } + } + return counterparties; +} +function hasSupplierScopedItemListQuestion(options) { + if (!String(options.counterpartyHint ?? "").trim()) { + return false; + } + const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`; + return /(?:какие?\s+товар|товар(?:ы|ов)?\s+(?:от|у)\s+поставщик|позици(?:и|я|ю)|номенклатур|лежат|остат(?:ок|ки|ка|ков))/iu.test(text); +} function omitAmountFromInventoryTraceEvidence(lines) { return lines.map((line) => String(line ?? "").replace(/\s+\|\s+сумма:\s+[^|]+(?=\s+\||$)/gu, "")); } @@ -131,6 +178,36 @@ function sumInventoryRowAmount(rows) { function sumInventoryRowQuantity(rows) { return rows.reduce((sum, row) => sum + (typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : 0), 0); } +function cleanInventoryScopeHint(value) { + const cleaned = String(value ?? "").replace(/\s+/gu, " ").trim(); + return cleaned.length > 0 ? cleaned : null; +} +function inventoryOnHandAccountScopeLabel(options) { + const accountHint = cleanInventoryScopeHint(options.accountHint); + if (accountHint) { + return `по счету ${accountHint}`; + } + const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`; + return /(?:^|[^\d])41(?:[.,]\d+)?(?:[^\d]|$)|41\s*сч[её]т/iu.test(text) ? "по счету 41" : null; +} +function inventoryOnHandWarehouseScopeLabel(options, uniqueWarehouses) { + const warehouseHint = cleanInventoryScopeHint(options.warehouseHint); + if (warehouseHint) { + return `по складу ${warehouseHint}`; + } + const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`; + if (uniqueWarehouses.length === 1 && /(?:по|на)\s+склад[уе]?/iu.test(text)) { + return `по складу ${uniqueWarehouses[0]}`; + } + return null; +} +function inventoryOnHandScopePhrase(options, uniqueWarehouses) { + const scopes = [ + inventoryOnHandAccountScopeLabel(options), + inventoryOnHandWarehouseScopeLabel(options, uniqueWarehouses) + ].filter((item) => Boolean(item)); + return scopes.length > 0 ? scopes.join(" и ") : "на складе"; +} function formatInventoryPercent(value, formatNumberWithDots) { return value === null || !Number.isFinite(value) ? "не подтверждена" : `${formatNumberWithDots(value, 2)}%`; } @@ -222,9 +299,10 @@ function composeInventoryReply(intent, rows, options, deps) { const uniqueItems = deps.uniqueStrings(positions.map((item) => item.item)); const uniqueWarehouses = deps.uniqueStrings(positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0)); const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0); + const scopePhrase = inventoryOnHandScopePhrase(options, uniqueWarehouses); const directAnswerLine = positions.length > 0 - ? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.` - : `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`; + ? `На ${deps.formatDateRu(asOfDate)} ${scopePhrase} подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.` + : `На ${deps.formatDateRu(asOfDate)} ${scopePhrase} подтвержденных товарных остатков не найдено.`; const lines = [directAnswerLine]; if (positions.length > 0) { const visiblePositionsLimit = 6; @@ -250,13 +328,22 @@ function composeInventoryReply(intent, rows, options, deps) { "- На дату среза товары с ненулевым остатком не найдены." ]); } - (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", [ + const summaryBullets = [ `Дата среза: ${deps.formatDateRu(asOfDate)}.`, `Позиции с остатком: ${deps.formatNumberWithDots(positions.length)}.`, `Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`, `Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`, "Общее количество не свожу в один управленческий показатель, потому что в остатках смешаны разнородные позиции." - ]); + ]; + const accountScope = inventoryOnHandAccountScopeLabel(options); + const warehouseScope = inventoryOnHandWarehouseScopeLabel(options, uniqueWarehouses); + if (accountScope) { + summaryBullets.splice(1, 0, `Срез ${accountScope}.`); + } + if (warehouseScope) { + summaryBullets.splice(accountScope ? 2 : 1, 0, `Срез ${warehouseScope}.`); + } + (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", summaryBullets); if (rows.length !== positions.length) { lines.push(`- Проверенных строк движения: ${deps.formatNumberWithDots(rows.length)}.`); } @@ -398,49 +485,111 @@ function composeInventoryReply(intent, rows, options, deps) { const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); const summary = deps.summarizeInventoryTraceRows(purchaseRows); normalizeInventoryTraceSummaryCounterparties(summary); - const unresolvedRows = purchaseRows.filter((row) => deps.extractInventoryCounterpartyCandidates(row).length === 0); - const unresolvedSupplierQuestion = /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(String(options.userMessage ?? "")); + const purchaseRowCounterparties = purchaseRows.map((row) => deps.uniqueStrings([ + ...deps.extractInventoryCounterpartyCandidates(row), + ...extractInventoryCounterpartiesFromEvidenceLines(deps.formatInventoryTraceRows([row], 1)) + ])); + const observedCounterparties = deps.uniqueStrings([ + ...summary.counterparties, + ...purchaseRowCounterparties.flat() + ]); + const unresolvedRows = purchaseRows.filter((_, index) => (purchaseRowCounterparties[index]?.length ?? 0) === 0); + const unresolvedSupplierQuestionText = `${String(options.userMessage ?? "")}\n${String(options.rawUserMessage ?? "")}`; + const unresolvedSupplierQuestion = /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(unresolvedSupplierQuestionText); if (unresolvedSupplierQuestion) { + const unresolvedItems = collectInventoryTraceItems(unresolvedRows); + const unresolvedItemPreviewLimit = 3; + const unresolvedDocumentPreviewLimit = 2; const directAnswerLine = unresolvedRows.length > 0 - ? `В текущем складском срезе найдено операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.` + ? unresolvedItems.length > 0 + ? `В текущем складском срезе на ${deps.formatDateRu(asOfDate)} найдено ${deps.formatNumberWithDots(unresolvedItems.length)} позиций без надежной привязки к поставщику; первые примеры ниже.` + : `В текущем складском срезе на ${deps.formatDateRu(asOfDate)} найдено операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.` : "В текущем складском срезе товары без явно выделенной привязки к поставщику в доступных данных не найдены."; const lines = [directAnswerLine]; + if (unresolvedItems.length > 0) { + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции без надежной привязки к поставщику:", unresolvedItems.slice(0, unresolvedItemPreviewLimit).map((item, index) => `${index + 1}. ${item}`)); + if (unresolvedItems.length > unresolvedItemPreviewLimit) { + lines.push(`- Показаны первые ${unresolvedItemPreviewLimit} из ${deps.formatNumberWithDots(unresolvedItems.length)} таких позиций.`); + } + } (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что проверили:", [ `Дата среза: ${deps.formatDateRu(asOfDate)}.`, `Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`, `Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`, - `Поставщиков, выделенных в остальных операциях: ${deps.formatNumberWithDots(summary.counterparties.length)}.` + `Поставщиков, выделенных в остальных операциях: ${deps.formatNumberWithDots(observedCounterparties.length)}.` ]); (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [ "Без партионного учета это проверка доступного закупочного следа по складскому срезу, а не юридическое доказательство владельца каждой партии." ]); if (unresolvedRows.length > 0) { - (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции без явно выделенного поставщика:", deps.formatInventoryTraceRows(unresolvedRows, 12)); + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(unresolvedRows, unresolvedDocumentPreviewLimit)); + if (unresolvedRows.length > unresolvedDocumentPreviewLimit) { + lines.push(`- Показаны первые ${unresolvedDocumentPreviewLimit} из ${deps.formatNumberWithDots(unresolvedRows.length)} строк без выделенного поставщика.`); + } } - else if (summary.counterparties.length > 0) { - lines.push(`- В доступном закупочном следе встречаются поставщики: ${summary.counterparties.slice(0, 6).join("; ")}.`); + else if (observedCounterparties.length > 0) { + lines.push(`- В доступном закупочном следе встречаются поставщики: ${observedCounterparties.slice(0, 6).join("; ")}.`); } return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(unresolvedRows.length > 0 ? "medium" : "strong", true)); } const warehouseLabel = summary.warehouses[0] ?? "не указанного склада"; - const directAnswerLine = summary.counterparties.length === 1 - ? `По складскому остатку ${warehouseLabel} выявлен поставщик: ${summary.counterparties[0]}.` - : summary.counterparties.length > 1 - ? `По складскому остатку ${warehouseLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 6).join("; ")}.` - : `По складскому остатку ${warehouseLabel} поставщик в доступных данных не выделен.`; + const purchasePeriodLabel = summary.firstPeriod && summary.lastPeriod + ? `${deps.inventoryTraceDateLabel(summary.firstPeriod)}..${deps.inventoryTraceDateLabel(summary.lastPeriod)}` + : "даты закупки не выделены"; + const supplierScopedItems = collectInventoryTraceItems(purchaseRows); + const supplierScopedItemQuestion = hasSupplierScopedItemListQuestion(options); + if (supplierScopedItemQuestion) { + const requestedSupplierLabel = summary.counterparties[0] ?? String(options.counterpartyHint ?? "").trim(); + const directAnswerLine = supplierScopedItems.length > 0 + ? `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} подтверждены позиции: ${inventoryItemLeadList(supplierScopedItems)}.` + : `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} позиции не подтверждены в доступном закупочном следе.`; + const lines = [directAnswerLine]; + if (supplierScopedItems.length > 0) { + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", supplierScopedItems.slice(0, 8).map((item, index) => `${index + 1}. ${item}`)); + if (supplierScopedItems.length > 8) { + lines.push(`- Показаны первые 8 из ${deps.formatNumberWithDots(supplierScopedItems.length)} позиций.`); + } + } + (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что проверили:", [ + `Дата среза: ${deps.formatDateRu(asOfDate)}.`, + `Период найденного закупочного следа: ${purchasePeriodLabel}.`, + `Закупочных документов / операций в выборке: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.` + ]); + (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [ + "Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии." + ]); + if (observedCounterparties.length > 0) { + lines.push(`- Найденные поставщики: ${inventoryPartyLeadList(observedCounterparties)}.`); + } + if (purchaseRows.length > 0) { + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT)); + if (purchaseRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) { + lines.push(`- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(purchaseRows.length)} найденных строк; полный след остается в подтвержденном срезе.`); + } + } + return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(purchaseRows.length > 0 ? (supplierScopedItems.length > 0 ? "strong" : "medium") : "medium", purchaseRows.length > 0)); + } + const directAnswerLine = observedCounterparties.length === 1 + ? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} выявлен поставщик закупочного следа: ${observedCounterparties[0]}.` + : observedCounterparties.length > 1 + ? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} найдено несколько поставщиков закупочного следа: ${inventoryPartyLeadList(observedCounterparties)}.` + : unresolvedRows.length > 0 + ? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} однозначный поставщик текущего остатка не подтвержден: в ${deps.formatNumberWithDots(unresolvedRows.length)} закупочных операциях поставщик не выделен.` + : `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} поставщик в доступных данных не выделен.`; const lines = [directAnswerLine]; + if (unresolvedRows.length > 0) { + lines.push(`По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} однозначная атрибуция части текущего остатка не подтверждена: ${deps.formatNumberWithDots(unresolvedRows.length)} закупочных операций без явно выделенного поставщика.`); + } (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что проверили:", [ `Дата среза: ${deps.formatDateRu(asOfDate)}.`, - `Первая найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`, - `Последняя найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`, - `Закупочных документов в выборке: ${deps.formatNumberWithDots(summary.documents.length)}.`, - `Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.` + `Период найденного закупочного следа: ${purchasePeriodLabel}.`, + `Закупочных документов / операций в выборке: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.` ]); (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [ "Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии." ]); - if (summary.counterparties.length > 0) { - lines.push(`- Найденные поставщики: ${summary.counterparties.slice(0, 6).join("; ")}.`); + if (observedCounterparties.length > 0) { + lines.push(`- Найденные поставщики: ${inventoryPartyLeadList(observedCounterparties)}.`); } else if (purchaseRows.length > 0) { lines.push("- Закупочные движения найдены, но поставщик не выделен отдельным полем в доступных данных."); @@ -448,11 +597,11 @@ function composeInventoryReply(intent, rows, options, deps) { else { lines.push("- В доступных данных не найдено закупочных движений по выбранному складскому срезу."); } - if (unresolvedRows.length > 0) { - lines.push(`- Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`); - } if (purchaseRows.length > 0) { - (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, 10)); + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT)); + if (purchaseRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) { + lines.push(`- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(purchaseRows.length)} найденных строк; полный след остается в подтвержденном срезе.`); + } } return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(purchaseRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium", purchaseRows.length > 0)); } @@ -770,17 +919,29 @@ function composeInventoryReply(intent, rows, options, deps) { const supplierMatches = inventoryRequestedPartyMatches(requestedSupplier, purchaseSummary.counterparties); const buyerMatches = inventoryRequestedPartyMatches(requestedBuyer, saleSummary.counterparties); const mismatchParts = []; - if (requestedSupplier && purchaseRows.length > 0 && !supplierMatches) { + if (requestedSupplier && purchaseRows.length === 0) { + mismatchParts.push(`закупка у запрошенного поставщика ${requestedSupplier} не найдена`); + } + else if (requestedSupplier && purchaseRows.length > 0 && !supplierMatches) { mismatchParts.push(`запрошенный поставщик ${requestedSupplier} не совпал с найденным поставщиком: ${inventoryPartyListOrUnknown(purchaseSummary.counterparties)}`); } - if (requestedBuyer && saleRows.length > 0 && !buyerMatches) { + if (requestedBuyer && saleRows.length === 0) { + mismatchParts.push(`продажа/выбытие запрошенному покупателю ${requestedBuyer} не найдены`); + } + else if (requestedBuyer && saleRows.length > 0 && !buyerMatches) { mismatchParts.push(`запрошенный покупатель ${requestedBuyer} не совпал с найденным покупателем: ${inventoryPartyListOrUnknown(saleSummary.counterparties)}`); } const directAnswerLine = mismatchParts.length > 0 ? `Запрошенная цепочка по товару ${itemLabel} полностью не подтверждена: ${mismatchParts.join("; ")}.` - : purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1 + : purchaseRows.length > 0 && saleRows.length > 0 && purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1 ? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.` - : `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`; + : purchaseRows.length > 0 && saleRows.length > 0 + ? `По товару ${itemLabel} найдены обе стороны цепочки поставки и продажи; участников несколько или они неоднозначны, детали ниже.` + : purchaseRows.length > 0 + ? `По товару ${itemLabel} закупочная часть цепочки подтверждена, но продажа/выбытие со счета 41.01 в доступных данных не найдены.` + : saleRows.length > 0 + ? `По товару ${itemLabel} найдена продажа/выбытие со счета 41.01, но закупочная часть цепочки в доступных данных не подтверждена.` + : `По товару ${itemLabel} цепочка поставки и продажи не подтверждена: движений по счету 41.01 в доступных данных не найдено.`; const lines = [directAnswerLine, "", "Подтверждение:"]; lines.push(`- Строк закупки на 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`); lines.push(`- Строк продажи со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`); diff --git a/llm_normalizer/backend/dist/services/address_runtime/semanticHintOverlay.js b/llm_normalizer/backend/dist/services/address_runtime/semanticHintOverlay.js index 73e8536..bca231c 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/semanticHintOverlay.js +++ b/llm_normalizer/backend/dist/services/address_runtime/semanticHintOverlay.js @@ -133,11 +133,17 @@ function applyAddressLlmSemanticHintsToExtraction(extraction, semanticHintsInput semanticFrame.anchor_value = scopeTargetText; } if (semanticHints.scope_target_kind === "counterparty" && scopeTargetText) { - extractedFilters.counterparty = scopeTargetText; - pushWarning(warnings, "counterparty_from_llm_semantics"); - semanticFrame.scope_kind = "explicit_anchor"; - semanticFrame.anchor_kind = "counterparty"; - semanticFrame.anchor_value = scopeTargetText; + if ((0, addressFilterExtractor_1.isLowQualityCounterpartyAnchorValue)(scopeTargetText)) { + delete extractedFilters.counterparty; + pushWarning(warnings, "counterparty_cleared_low_quality_llm_semantics"); + } + else { + extractedFilters.counterparty = scopeTargetText; + pushWarning(warnings, "counterparty_from_llm_semantics"); + semanticFrame.scope_kind = "explicit_anchor"; + semanticFrame.anchor_kind = "counterparty"; + semanticFrame.anchor_value = scopeTargetText; + } } if (semanticHints.scope_target_kind === "contract" && scopeTargetText) { extractedFilters.contract = scopeTargetText; @@ -159,6 +165,11 @@ function applyAddressLlmSemanticHintsToExtraction(extraction, semanticHintsInput pushWarning(warnings, "item_llm_semantics_ignored"); } } + if (toNonEmptyString(extractedFilters.counterparty) && + (0, addressFilterExtractor_1.isLowQualityCounterpartyAnchorValue)(String(extractedFilters.counterparty))) { + delete extractedFilters.counterparty; + pushWarning(warnings, "counterparty_cleared_low_quality_llm_semantics"); + } return { ...extraction, extracted_filters: extractedFilters, diff --git a/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js index a96b764..88d27fc 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js @@ -31,6 +31,182 @@ function toNullableBoolean(value) { function normalizeAddressReplyType(value) { return value === "factual" || value === "partial_coverage" ? value : "partial_coverage"; } +function uniqueNormalizedStrings(values) { + const result = []; + const seen = new Set(); + for (const value of values) { + const text = String(value ?? "").trim(); + if (!text) { + continue; + } + const key = text.toLocaleLowerCase("ru-RU").replace(/ё/g, "е"); + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(text); + } + return result; +} +function extractInventorySupplierOverlapCounterpartiesFromReply(replyText) { + const values = []; + const pattern = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|\r\n]+)/giu; + for (const match of replyText.matchAll(pattern)) { + const value = toNullableString(match[1]?.replace(/[.;,\s]+$/u, "")); + if (!value || /^(?:\u043d\u0435\s+\u0432\u044b\u0434\u0435\u043b\u0435\u043d|\u043d\/\u0434|unknown)$/iu.test(value)) { + continue; + } + values.push(value); + } + return uniqueNormalizedStrings(values); +} +function formatInventorySupplierOverlapCounterpartyLead(counterparties, limit = 6) { + const visible = counterparties.slice(0, limit); + const remaining = counterparties.length - visible.length; + const suffix = remaining > 0 ? `; и еще ${remaining}` : ""; + return `${visible.join("; ")}${suffix}`; +} +function hasInventorySupplierOverlapDefensiveLead(firstLine) { + const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435"); + const startsWithStockScope = /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test(normalized); + const hasDefensiveAttribution = /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test(normalized); + return startsWithStockScope && hasDefensiveAttribution; +} +function firstNonEmptyReplyLine(text) { + return String(text ?? "") + .split(/\r?\n/u) + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? ""; +} +function hasInventorySupplierOverlapCounterpartyLead(firstLine) { + const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435"); + return (/^\u043f\u043e\s+\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443/u.test(normalized) && + /\u043d\u0430\u0439\u0434\u0435\u043d/u.test(normalized) && + /\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a/u.test(normalized) && + /\u0437\u0430\u043a\u0443\u043f\u043e\u0447\u043d\u043e\u0433\u043e\s+\u0441\u043b\u0435\u0434\u0430/u.test(normalized)); +} +function preserveInventorySupplierOverlapCounterpartyLeadAfterSanitize(input) { + const debug = toRecordObject(input.addressDebug); + const detectedIntent = toNullableString(debug?.detected_intent); + if (detectedIntent !== "inventory_supplier_stock_overlap_as_of_date") { + return { text: input.sanitizedReply, applied: false }; + } + const rawLead = firstNonEmptyReplyLine(input.rawReply); + if (!hasInventorySupplierOverlapCounterpartyLead(rawLead)) { + return { text: input.sanitizedReply, applied: false }; + } + const sanitizedLead = firstNonEmptyReplyLine(input.sanitizedReply); + if (hasInventorySupplierOverlapCounterpartyLead(sanitizedLead)) { + return { text: input.sanitizedReply, applied: false }; + } + const sanitizedText = String(input.sanitizedReply ?? "").trim(); + return { + text: sanitizedText ? [rawLead, sanitizedText].join("\n\n") : rawLead, + applied: true + }; +} +function inspectInventorySupplierOverlapDefensiveLead(firstLine) { + const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435"); + return { + stockScopeMatch: /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test(normalized), + defensiveMatch: /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test(normalized) + }; +} +function buildInventorySupplierOverlapSurfaceRepairAudit(input) { + return { + schema_version: "inventory_supplier_overlap_surface_repair_v1", + applied: input.applied, + detected_intent: input.detectedIntent, + counterparties: input.counterparties ?? [], + first_line_sample: input.firstLineSample ?? null, + stock_scope_match: input.stockScopeMatch ?? null, + defensive_match: input.defensiveMatch ?? null, + reason_codes: input.reasonCodes + }; +} +function repairInventorySupplierOverlapSurfaceReply(replyText, addressDebug) { + const text = String(replyText ?? ""); + const debug = toRecordObject(addressDebug); + const detectedIntent = toNullableString(debug?.detected_intent); + if (detectedIntent !== "inventory_supplier_stock_overlap_as_of_date") { + return { + text, + audit: buildInventorySupplierOverlapSurfaceRepairAudit({ + applied: false, + detectedIntent, + reasonCodes: ["inventory_supplier_overlap_surface_repair_intent_not_applicable"] + }) + }; + } + const lines = text.split(/\r?\n/u); + const firstLine = lines[0]?.trim() ?? ""; + const leadInspection = inspectInventorySupplierOverlapDefensiveLead(firstLine); + if (!hasInventorySupplierOverlapDefensiveLead(firstLine)) { + return { + text, + audit: buildInventorySupplierOverlapSurfaceRepairAudit({ + applied: false, + detectedIntent, + firstLineSample: firstLine.slice(0, 220), + stockScopeMatch: leadInspection.stockScopeMatch, + defensiveMatch: leadInspection.defensiveMatch, + reasonCodes: ["inventory_supplier_overlap_surface_repair_first_line_not_defensive"] + }) + }; + } + const counterparties = extractInventorySupplierOverlapCounterpartiesFromReply(text); + if (counterparties.length === 0) { + return { + text, + audit: buildInventorySupplierOverlapSurfaceRepairAudit({ + applied: false, + detectedIntent, + counterparties, + firstLineSample: firstLine.slice(0, 220), + stockScopeMatch: leadInspection.stockScopeMatch, + defensiveMatch: leadInspection.defensiveMatch, + reasonCodes: ["inventory_supplier_overlap_surface_repair_no_counterparties"] + }) + }; + } + const repairedLead = counterparties.length === 1 + ? `По складскому остатку найден поставщик закупочного следа: ${counterparties[0]}.` + : `По складскому остатку найдено несколько поставщиков закупочного следа: ${formatInventorySupplierOverlapCounterpartyLead(counterparties)}.`; + return { + text: [repairedLead, firstLine, ...lines.slice(1)].join("\n"), + audit: buildInventorySupplierOverlapSurfaceRepairAudit({ + applied: true, + detectedIntent, + counterparties, + firstLineSample: firstLine.slice(0, 220), + stockScopeMatch: leadInspection.stockScopeMatch, + defensiveMatch: leadInspection.defensiveMatch, + reasonCodes: ["inventory_supplier_overlap_surface_repair_applied"] + }) + }; +} +function extractSingleWarehouseScopeFromReply(replyText) { + const values = []; + const normalized = String(replyText ?? ""); + const byFieldPattern = /(?:^|\|)\s*склад\s*:\s*([^|\r\n]+)/giu; + for (const match of normalized.matchAll(byFieldPattern)) { + const value = toNullableString(match[1]?.replace(/[.;,\s]+$/u, "")); + if (value) { + values.push(value); + } + } + const fieldUnique = uniqueNormalizedStrings(values); + if (fieldUnique.length > 0) { + return fieldUnique.length === 1 ? fieldUnique[0] : null; + } + const leadMatch = normalized.match(/(?:по|на)\s+склад[уе]?\s+([^.,;\r\n]+)/iu); + const leadWarehouse = toNullableString(leadMatch?.[1]?.replace(/[.;,\s]+$/u, "")); + if (leadWarehouse) { + values.push(leadWarehouse); + } + const unique = uniqueNormalizedStrings(values); + return unique.length === 1 ? unique[0] : null; +} function sameBusinessLabel(left, right) { const normalizedLeft = toNullableString(left)?.toLocaleLowerCase("ru-RU").replace(/ё/g, "е"); const normalizedRight = toNullableString(right)?.toLocaleLowerCase("ru-RU").replace(/ё/g, "е"); @@ -65,7 +241,7 @@ function cleanComparisonScopeCompanyLine(line, organization) { .replace(/\bcompany-level\b/giu, "общий по компании") .replace(/\breusable bundle\b/giu, "сохраненный подтвержденный срез"); if (organization) { - clean = clean.replace(/по компании\s+Альтернатива Плюс/iu, `по компании ${organization}`); + clean = clean.replace(/по компании\s+.+?(?=\s+(?:подтвержден|подтвержд[её]н|зафиксирован|показан|есть)\b|:|$)/iu, `по компании ${organization}`); } return clean.trim(); } @@ -77,9 +253,7 @@ function hasComparisonScopeProofCue(value) { return /(?:\bсравни\b|собери\s+коротк\p{L}*\s+итог|коротк\p{L}*\s+сравн|что\s+.*подтверд\p{L}*.*(?:компан|контрагент|свк)|что\s+.*отдельно\s+по\s+(?:группа\s+свк|выбранн\p{L}*\s+контрагент)|какие\s+выводы\s+можно\s+делать\s+и\s+какие\s+нельзя)/iu.test(text); } function buildComparisonScopeProofReply(input) { - if (!hasComparisonScopeProofCue(input.userMessage)) { - return null; - } + const legalOrganizationFromCurrentTurn = legalOrganizationLabelFromClarification(input.userMessage); const entryPoint = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1); const turnInput = toRecordObject(entryPoint?.turn_input); const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref); @@ -87,6 +261,9 @@ function buildComparisonScopeProofReply(input) { if (!isBusinessOverview) { return null; } + if (!hasComparisonScopeProofCue(input.userMessage) && !legalOrganizationFromCurrentTurn) { + return null; + } const separateCandidates = Array.isArray(turnMeaning?.business_overview_separate_entity_candidates) ? turnMeaning.business_overview_separate_entity_candidates : []; @@ -113,7 +290,7 @@ function buildComparisonScopeProofReply(input) { if (!incomingAmount && !outgoingAmount && !netAmount) { return null; } - const organization = legalOrganizationLabelFromClarification(input.userMessage) + const organization = legalOrganizationFromCurrentTurn ?? toNullableString(turnMeaning?.explicit_organization_scope) ?? toNullableString(toRecordObject(comparisonScope?.organization)?.label); const documentCount = Number(toRecordObject(documentBundle)?.document_count); @@ -277,8 +454,21 @@ function normalizeLlmPreDecomposeMeta(value) { } function runAssistantAddressLaneResponseRuntime(input) { const finalizeAddressTurnSafe = input.finalizeAddressTurn ?? assistantAddressTurnFinalizeRuntimeAdapter_1.finalizeAssistantAddressTurn; - const safeAddressReply = input.sanitizeOutgoingAssistantText(input.addressLane.reply_text); const debug = input.buildAddressDebugPayload(input.addressLane.debug, input.llmPreDecomposeMeta); + const surfaceRepair = repairInventorySupplierOverlapSurfaceReply(input.addressLane.reply_text, debug); + const safeAddressReplyBase = input.sanitizeOutgoingAssistantText(surfaceRepair.text); + const preservedAddressReply = preserveInventorySupplierOverlapCounterpartyLeadAfterSanitize({ + rawReply: surfaceRepair.text, + sanitizedReply: safeAddressReplyBase, + addressDebug: debug + }); + const safeAddressReply = preservedAddressReply.text; + if (surfaceRepair.audit) { + debug.inventory_supplier_overlap_surface_repair_v1 = { + ...surfaceRepair.audit, + lead_preserved_after_sanitize: preservedAddressReply.applied + }; + } const followupOffer = input.buildAddressFollowupOffer(debug); if (followupOffer) { debug.address_followup_offer = followupOffer; @@ -309,14 +499,36 @@ function runAssistantAddressLaneResponseRuntime(input) { const rootFilters = followupContextSource?.root_filters && typeof followupContextSource.root_filters === "object" ? followupContextSource.root_filters : null; - if (rootIntent || currentFrameKind) { + const detectedIntent = input.toNonEmptyString(debug.detected_intent); + const rootFrameWarehouse = input.toNonEmptyString(rootFilters?.warehouse) ?? + input.toNonEmptyString(debugFilters?.warehouse) ?? + (detectedIntent === "inventory_on_hand_as_of_date" ? extractSingleWarehouseScopeFromReply(safeAddressReply) : null); + const shouldPersistInventoryRootFrame = rootIntent || currentFrameKind || detectedIntent === "inventory_on_hand_as_of_date"; + if (shouldPersistInventoryRootFrame) { + const rootFiltersForDebug = { + ...(rootFilters ?? {}), + organization: input.toNonEmptyString(rootFilters?.organization) ?? input.toNonEmptyString(debugFilters?.organization), + warehouse: rootFrameWarehouse, + as_of_date: input.toNonEmptyString(rootFilters?.as_of_date) ?? input.toNonEmptyString(debugFilters?.as_of_date), + period_from: input.toNonEmptyString(rootFilters?.period_from) ?? input.toNonEmptyString(debugFilters?.period_from), + period_to: input.toNonEmptyString(rootFilters?.period_to) ?? input.toNonEmptyString(debugFilters?.period_to) + }; + for (const key of Object.keys(rootFiltersForDebug)) { + if (!input.toNonEmptyString(rootFiltersForDebug[key])) { + delete rootFiltersForDebug[key]; + } + } debug.address_root_frame_context = { - root_intent: rootIntent, - current_frame_kind: currentFrameKind, - organization: input.toNonEmptyString(rootFilters?.organization), - as_of_date: input.toNonEmptyString(rootFilters?.as_of_date), - period_from: input.toNonEmptyString(rootFilters?.period_from), - period_to: input.toNonEmptyString(rootFilters?.period_to) + root_intent: rootIntent ?? (detectedIntent === "inventory_on_hand_as_of_date" ? "inventory_on_hand_as_of_date" : null), + root_filters: rootFiltersForDebug, + root_anchor_type: input.toNonEmptyString(followupContextSource?.root_anchor_type) ?? undefined, + root_anchor_value: input.toNonEmptyString(followupContextSource?.root_anchor_value) ?? undefined, + current_frame_kind: currentFrameKind ?? (detectedIntent === "inventory_on_hand_as_of_date" ? "inventory_root" : null), + organization: input.toNonEmptyString(rootFiltersForDebug.organization), + warehouse: input.toNonEmptyString(rootFiltersForDebug.warehouse), + as_of_date: input.toNonEmptyString(rootFiltersForDebug.as_of_date), + period_from: input.toNonEmptyString(rootFiltersForDebug.period_from), + period_to: input.toNonEmptyString(rootFiltersForDebug.period_to) }; } const debugWithRuntimeContracts = (0, assistantRuntimeContractResolver_1.attachAssistantRuntimeContractShadow)(debug, { diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js index 84592fb..c7fcda9 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -948,9 +948,21 @@ function buildInventoryRootFrameFromAddressDebug(debug, toNonEmptyString = fallb return null; } const rootFiltersCandidate = toRecordObject(rootFrameContext?.root_filters); + const rootFrameFiltersCandidate = rootFrameContext + ? { + organization: toNonEmptyString(rootFrameContext.organization), + warehouse: toNonEmptyString(rootFrameContext.warehouse), + as_of_date: toNonEmptyString(rootFrameContext.as_of_date), + period_from: toNonEmptyString(rootFrameContext.period_from), + period_to: toNonEmptyString(rootFrameContext.period_to) + } + : null; + const rootFrameFilters = rootFrameFiltersCandidate + ? Object.fromEntries(Object.entries(rootFrameFiltersCandidate).filter(([, value]) => Boolean(value))) + : null; const filters = { ...(rootFiltersCandidate ?? {}), - ...(rootFiltersCandidate ? {} : extractedFilters) + ...(rootFiltersCandidate ? {} : rootFrameFilters && Object.keys(rootFrameFilters).length > 0 ? rootFrameFilters : extractedFilters) }; if (!filters.organization) { const organization = readAddressDebugOrganization(debug, toNonEmptyString); diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index d60955c..aaed317 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -405,6 +405,30 @@ function hasInventoryMarginRankingAddressReply(input, entryPoint) { selectedRecipe === "address_inventory_margin_ranking_for_nomenclature_v1" || capabilityId === "inventory_inventory_margin_ranking_for_nomenclature"); } +function hasExactInventoryPurchaseToSaleChainAddressReply(input, entryPoint) { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + const source = String(input.currentReplySource ?? input.livingChatSource ?? "").trim().toLowerCase(); + if (source !== "address_query_runtime_v1" && source !== "address_exact" && source !== "address_lane") { + return false; + } + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); + const capabilityId = toNonEmptyString(input.addressRuntimeMeta?.capability_id) ?? + toNonEmptyString(input.addressRuntimeMeta?.capability_contract_id); + if (detectedIntent !== "inventory_purchase_to_sale_chain" && + selectedRecipe !== "address_inventory_purchase_to_sale_chain_v1" && + capabilityId !== "inventory_inventory_purchase_to_sale_chain") { + return false; + } + const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status); + const responseType = toNonEmptyString(input.addressRuntimeMeta?.response_type); + return Boolean(hasConfirmedAddressExecution(input) || mcpCallStatus === "matched_non_empty" || responseType === "FACTUAL_LIST"); +} function hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint) { if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { return false; @@ -471,6 +495,14 @@ function hasExactMatchedFactualAddressReply(input, entryPoint) { } if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + const turnMeaning = readDiscoveryTurnMeaning(entryPoint); + const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); + const askedAction = toNonEmptyString(turnMeaning?.asked_action_family); + if (detectedIntent === "inventory_supplier_stock_overlap_as_of_date" && + hasConfirmedAddressExecution(input) && + (askedDomain === "entity_resolution" || askedAction === "search_business_entity")) { + return true; + } if (!(isMetadataDiscoveryTurn(entryPoint) && isInventoryExactAddressIntent(detectedIntent))) { return false; } @@ -682,6 +714,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { const exactBankOperationsAddressReply = hasExactBankOperationsAddressReply(input, entryPoint); const exactDocumentListAddressReply = hasExactDocumentListAddressReply(input, entryPoint); const inventoryMarginRankingAddressReply = hasInventoryMarginRankingAddressReply(input, entryPoint); + const exactInventoryPurchaseToSaleChainAddressReply = hasExactInventoryPurchaseToSaleChainAddressReply(input, entryPoint); const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint); const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint); const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint); @@ -689,6 +722,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { const exactBankOperationsProtectsCurrent = exactBankOperationsAddressReply && !semanticConflictWithDiscoveryTurnMeaning && !valueFlowActionConflictWithDiscoveryTurnMeaning; + const exactInventoryChainProtectsCurrent = exactInventoryPurchaseToSaleChainAddressReply && + candidate.candidate_status === "clarification_candidate" && + valueFlowActionConflictWithDiscoveryTurnMeaning; if (!entryPoint) { pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); } @@ -755,6 +791,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { if (inventoryMarginRankingAddressReply) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_inventory_margin_ranking_address_reply"); } + if (exactInventoryChainProtectsCurrent) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_exact_inventory_chain_reply_over_value_flow_clarification"); + } if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate"); } @@ -787,6 +826,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { !exactBankOperationsProtectsCurrent && !exactDocumentListAddressReply && !inventoryMarginRankingAddressReply && + !exactInventoryChainProtectsCurrent && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && candidate.eligible_for_future_hot_runtime && diff --git a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js index a115bcb..93a9ff3 100644 --- a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js @@ -67,6 +67,13 @@ function detectSupportedIntent(text, deps) { reason: "address_intent_resolver_current_turn_signal" }; } + if (hasInventorySupplierStockOverlapExactSignal(text)) { + return { + intent: "inventory_supplier_stock_overlap_as_of_date", + confidence: "high", + reason: "inventory_supplier_stock_overlap_current_turn_signal" + }; + } if (/(?:\u043a\u0442\u043e\s+\u043d\u0430\u043c(?:\s+\p{L}+){0,4}\s+\u0434\u043e\u043b\u0436|\u043d\u0430\u043c\s+\u043a\u0442\u043e(?:\s+\p{L}+){0,4}\s+\u0434\u043e\u043b\u0436|\u0434\u0435\u0431\u0438\u0442\u043e\u0440|\u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a|\breceivables?\b)/iu.test(text)) { return { intent: "receivables_confirmed_as_of_date", @@ -214,6 +221,17 @@ function hasSelectedObjectInventoryExactSignal(text) { } return /(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u043f\u0440\u0438\u0431\u044b\u043b|\u043c\u0430\u0440\u0436|\u043f\u0440\u043e\u0434\u0430\u0436|\u043f\u0440\u043e\u0434\u0430\u043b|\u0437\u0430\u043a\u0443\u043f|\u043f\u043e\u043a\u0443\u043f|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0446\u0435\u043f\u043e\u0447|profit|margin|sale|purchase|document)/iu.test(normalized); } +function hasInventorySupplierStockOverlapExactSignal(text) { + const normalized = String(text ?? ""); + if (!normalized) { + return false; + } + const hasStockScope = /(?:\u0441\u043a\u043b\u0430\u0434|\u043e\u0441\u0442\u0430\u0442|\u043b\u0435\u0436\p{L}*|\u043d\u0430\u0445\u043e\u0434\p{L}*|\u0447\u0438\u0441\u043b\p{L}*|warehouse|stock|inventory|on\s+hand)/iu.test(normalized); + const hasSupplierCue = /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\u0441\u0442\u0430\u0432\p{L}*|\u0437\u0430\u043a\u0443\u043f|\u043a\u0443\u043f\p{L}*|\u043f\u0440\u0438\u043e\u0431\u0440\p{L}*|supplier|vendor|purchase|bought|supplied|procurement)/iu.test(normalized); + const hasOverlapAction = /(?:\u0443\s+\u043a\u0430\u043a\u043e\u0433\u043e\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\s+\u043a\u0430\u043a\u043e\u043c\u0443\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u0442\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u043f\u0440\u0435\u0434\u0435\u043b\p{L}*[\s\S]{0,80}\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0430\u0439\u0442\p{L}*[\s\S]{0,80}\u0442\u043e\u0432\u0430\u0440|\u043a\u0430\u043a\u0438\u0435\s+\u0442\u043e\u0432\u0430\u0440|supplier[\s\S]{0,80}(?:stock|inventory)|(?:stock|inventory)[\s\S]{0,80}supplier)/iu.test(normalized); + const hasSupplierIdentityAsk = /(?:\u043a\u0430\u043a\u043e(?:\u0433\u043e|\u043c\u0443)\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u043f\u0440\u0435\u0434\u0435\u043b\p{L}*\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|which\s+supplier|what\s+supplier)/iu.test(normalized); + return hasStockScope && hasSupplierCue && (hasOverlapAction || hasSupplierIdentityAsk); +} function hasOrganizationLevelEarningsOverviewSignal(text) { const normalized = String(text ?? ""); if (!normalized || hasExplicitCounterpartyValueObject(normalized)) { @@ -381,12 +399,15 @@ function createAssistantTurnMeaningPolicy(deps = {}) { const counterpartyBidirectionalValueFlow = compactOrganizationCashflowDisplay ? null : detectCounterpartyBidirectionalValueFlowFamily(joinedText); const counterpartyTurnover = compactOrganizationCashflowDisplay ? null : detectCounterpartyTurnoverFamily(joinedText); const selectedObjectInventoryExact = hasSelectedObjectInventoryExactSignal(joinedText); + const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps); + const inventorySupplierStockOverlapExact = supportedIntent?.intent === "inventory_supplier_stock_overlap_as_of_date" || + llmIntent === "inventory_supplier_stock_overlap_as_of_date" || + hasInventorySupplierStockOverlapExactSignal(joinedText); const broadBusinessEvaluation = compactOrganizationCashflowDisplay ? { family: "broad_business_evaluation" } - : selectedObjectInventoryExact || counterpartyBidirectionalValueFlow?.family + : selectedObjectInventoryExact || inventorySupplierStockOverlapExact || counterpartyBidirectionalValueFlow?.family ? null : detectBroadBusinessEvaluation(joinedText); - const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps); const explicitIntentCandidate = broadBusinessEvaluation?.family ? null : supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null); @@ -454,11 +475,13 @@ function createAssistantTurnMeaningPolicy(deps = {}) { ? "confirmed_snapshot" : explicitIntentCandidate === "vat_payable_forecast" ? "forecast" - : explicitIntentCandidate === "list_documents_by_counterparty" - ? "list_documents" - : counterpartyTurnover?.family - ? "counterparty_value_or_turnover" - : null; + : explicitIntentCandidate === "inventory_supplier_stock_overlap_as_of_date" + ? "supplier_overlap" + : explicitIntentCandidate === "list_documents_by_counterparty" + ? "list_documents" + : counterpartyTurnover?.family + ? "counterparty_value_or_turnover" + : null; const staleReplayForbidden = Boolean(unsupportedFamily || broadBusinessEvaluation?.family || (counterpartyBidirectionalValueFlow?.entity && !explicitIntentCandidate) || diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 7c0f78c..c81f64a 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -726,17 +726,21 @@ function extractKnownFinancialCounterpartyAnchor(text: string): string | undefin return undefined; } -function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean { +export function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean { const value = String(rawValue ?? "") .trim() .toLowerCase() - .replace(/ё/g, "е"); + .replace(/ё/g, "е") + .replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ""); if (!value) { return true; } if (/(?:за\s+вс[её]\s+время|за\s+всю\s+истори(?:ю|и)|all\s+time|entire\s+period|full\s+history)/iu.test(value)) { return true; } + if (/^(?:товар(?:ы|а|ов|у|ом|ами|ах)?|номенклатур(?:а|ы|у|ой|ами)?|позици(?:я|и|ю|ей)|остат(?:ок|ки|ка|ков|кам|ками)?|склад|складе|складу)$/iu.test(value)) { + return true; + } if ( /^(?:или|это|там|может|можно|обычн\p{L}*|клиентск\p{L}*|банковск\p{L}*(?:\/|\s+и\s+)?финансов\p{L}*)\b/iu.test( value diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 95ca502..fb8f54a 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -2465,6 +2465,16 @@ function asksForUnresolvedInventorySupplierLink(userMessage: string | null | und ); } +function messagePairAsksForUnresolvedInventorySupplierLink( + userMessage: string | null | undefined, + rawUserMessage: string | null | undefined +): boolean { + return ( + asksForUnresolvedInventorySupplierLink(userMessage) || + asksForUnresolvedInventorySupplierLink(rawUserMessage) + ); +} + function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilterSet): boolean { if (Array.isArray((filters as { warnings?: unknown }).warnings) && (filters as { warnings?: string[] }).warnings?.includes("exact_historical_period_window_requested")) { return false; @@ -2530,7 +2540,12 @@ function shouldDetachLifecycleExecutionFromSnapshotContext( return false; } + const historyIntent = + intent === "inventory_sale_trace_for_item" || + intent === "inventory_profitability_for_item" || + intent === "inventory_purchase_to_sale_chain"; return ( + (historyIntent && reasons.includes("as_of_date_from_analysis_context")) || reasons.includes("period_window_semantic_from_inventory_snapshot_context") || reasons.includes("period_window_semantic_from_inventory_as_of_month") || reasons.includes("as_of_date_from_followup_context") || @@ -2629,6 +2644,90 @@ function injectNoticeAfterLeadLine(text: string, notice: string): string { return [lines[0], normalizedNotice, ...lines.slice(1)].join("\n"); } +function injectNoticeBeforeLeadLine(text: string, notice: string): string { + const normalizedText = typeof text === "string" ? text : ""; + const normalizedNotice = typeof notice === "string" ? notice.trim() : ""; + if (!normalizedText.trim()) { + return normalizedNotice; + } + if (!normalizedNotice) { + return normalizedText; + } + return [normalizedNotice, normalizedText].join("\n"); +} + +function injectAutoBroadenedPeriodNotice(intent: string, text: string, notice: string): string { + if (intent === "inventory_supplier_stock_overlap_as_of_date") { + return injectNoticeBeforeLeadLine(text, notice); + } + return injectNoticeAfterLeadLine(text, notice); +} + +function extractSupplierOverlapReplyCounterparties(text: string): string[] { + const counterparties: string[] = []; + const pattern = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|\n]+)/giu; + for (const match of String(text ?? "").matchAll(pattern)) { + const value = String(match[1] ?? "").replace(/\s+/gu, " ").trim(); + if (!value || /^(?:\u043d\u0435\s+\u0432\u044b\u0434\u0435\u043b\u0435\u043d|\u043d\/\u0434|unknown)$/iu.test(value)) { + continue; + } + counterparties.push(value); + } + return uniqueStrings(counterparties); +} + +function formatSupplierOverlapCounterpartyLead(counterparties: string[], limit = 6): string { + const visible = counterparties.slice(0, limit); + const remaining = counterparties.length - visible.length; + const suffix = remaining > 0 ? `; и еще ${remaining}` : ""; + return `${visible.join("; ")}${suffix}`; +} + +function hasInventorySupplierOverlapDefensiveLead(firstLine: string): boolean { + const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435"); + const startsWithStockScope = /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test( + normalized + ); + const hasDefensiveAttribution = /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test( + normalized + ); + return startsWithStockScope && hasDefensiveAttribution; +} + +export function ensureInventorySupplierOverlapLeadLine(intent: string, text: string): string { + if (intent !== "inventory_supplier_stock_overlap_as_of_date") { + return text; + } + const normalizedText = typeof text === "string" ? text : ""; + const lines = normalizedText.split(/\r?\n/u); + const firstLine = lines[0]?.trim() ?? ""; + const displayedCounterparties = extractSupplierOverlapReplyCounterparties(normalizedText); + if ( + displayedCounterparties.length > 0 && + hasInventorySupplierOverlapDefensiveLead(firstLine) + ) { + const repairedLead = + displayedCounterparties.length === 1 + ? `По складскому остатку найден поставщик закупочного следа: ${displayedCounterparties[0]}.` + : `По складскому остатку найдено несколько поставщиков закупочного следа: ${formatSupplierOverlapCounterpartyLead(displayedCounterparties)}.`; + return [repairedLead, firstLine, ...lines.slice(1)].join("\n"); + } + if ( + !normalizedText.trim() || + /^По\s+(?:складскому\s+остатку|окну)\b/iu.test(firstLine) || + /^В\s+текущем\s+складском\s+срезе\b/iu.test(firstLine) + ) { + return normalizedText; + } + if (!/^(?:Что\s+проверили:|Часть\s+закупочных\s+операций|Опорные\s+документы:)/iu.test(firstLine)) { + return normalizedText; + } + return [ + "По складскому остатку однозначный поставщик текущего остатка не подтвержден; ниже показан проверенный закупочный след и ограничения.", + normalizedText + ].join("\n"); +} + function runtimeReadinessForLimitedCategory(category: AddressLimitedReasonCategory): AddressRuntimeReadiness { if (category === "empty_match" || category === "missing_anchor") { return "LIVE_QUERYABLE_WITH_LIMITS"; @@ -3684,7 +3783,7 @@ export class AddressQueryService { intent.intent === "inventory_supplier_stock_overlap_as_of_date" && !toNonEmptyFilterValue(filters.extracted_filters.period_from) && !toNonEmptyFilterValue(filters.extracted_filters.period_to) && - asksForUnresolvedInventorySupplierLink(userMessage) && + messagePairAsksForUnresolvedInventorySupplierLink(userMessage, options.rawUserMessage) && !/(?:Р·Р°\s+РІСЃ[её]\s+время|Р·Р°\s+любой\s+период|all[\s-]?time|all\s+periods?)/iu.test(userMessage) ) { const monthWindow = deriveMonthWindowForDate(filters.extracted_filters.as_of_date); @@ -3808,6 +3907,21 @@ export class AddressQueryService { receivablesConfirmedExecution?.executionFilters ?? vatPayableConfirmedExecution?.executionFilters ?? filters.extracted_filters; + if ( + intent.intent === "inventory_aging_by_purchase_date" && + toNonEmptyFilterValue(executionFilters.as_of_date) && + (toNonEmptyFilterValue(executionFilters.period_from) || toNonEmptyFilterValue(executionFilters.period_to)) + ) { + executionFilters = { ...executionFilters }; + delete executionFilters.period_from; + delete executionFilters.period_to; + if (!filters.warnings.includes("period_window_detached_for_inventory_aging_execution")) { + filters.warnings.push("period_window_detached_for_inventory_aging_execution"); + } + if (!baseReasons.includes("period_window_detached_for_inventory_aging_execution")) { + baseReasons.push("period_window_detached_for_inventory_aging_execution"); + } + } if ( intent.intent === "counterparty_activity_lifecycle" && typeof executionFilters.counterparty === "string" && @@ -3992,6 +4106,7 @@ export class AddressQueryService { : typeof filterSet.account === "string" ? filterSet.account : undefined, + warehouseHint: typeof filterSet.warehouse === "string" ? filterSet.warehouse : undefined, periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined, periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined, asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined, @@ -4703,7 +4818,11 @@ export class AddressQueryService { ); return { handled: true, - reply_text: repairCounterpartyReplyLabel(replyText, intent.intent, filters.extracted_filters), + reply_text: repairCounterpartyReplyLabel( + ensureInventorySupplierOverlapLeadLine(intent.intent, replyText), + intent.intent, + filters.extracted_filters + ), reply_type: inferReplyType(responseType), response_type: responseType, debug: debugPayload @@ -4871,7 +4990,7 @@ export class AddressQueryService { return { handled: true, reply_text: repairCounterpartyReplyLabel( - input.replyText, + ensureInventorySupplierOverlapLeadLine(intent.intent, input.replyText), intent.intent, (input.extractedFilters ?? filters.extracted_filters) as AddressFilterSet ), @@ -5207,7 +5326,7 @@ export class AddressQueryService { resultMode: broadenedCoverageEvidence.result_mode ?? undefined }); return buildFactualExecutionResult({ - replyText: injectNoticeAfterLeadLine(broadenedFactual.text, broadenedPrefix), + replyText: injectAutoBroadenedPeriodNotice(intent.intent, broadenedFactual.text, broadenedPrefix), responseType: broadenedFactual.responseType, responseSemantics: broadenedFactual.semantics, selectedRecipe: broadenedSelection.selected_recipe.recipe_id, diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index c485b58..d11f49d 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -1552,20 +1552,11 @@ function buildInventoryAgingByPurchaseDateQuery(filters: AddressFilterSet, resol buildInventoryItemReferenceCondition(filters, ["Остатки.Субконто1"]) ]; const onHandWhereClause = buildStaticWhereClause(onHandScopeConditions); - const currentStockItemSubquery = [ - "(ВЫБРАТЬ РАЗЛИЧНЫЕ", - " Остатки.Субконто1", - " ИЗ", - ` РегистрБухгалтерии.Хозрасчетный.Остатки(${asOfExpr}, , , ) КАК Остатки`, - buildStaticWhereClause(onHandScopeConditions, " "), - ")" - ].join("\n"); const purchaseWhereClause = buildStaticWhereClause([ "Товары.Ссылка.Проведен = ИСТИНА", `Товары.Ссылка.Дата <= ${asOfExpr}`, buildOrganizationReferenceCondition(filters, ["Товары.Ссылка.Организация"]), - buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]), - `Товары.Номенклатура В ${currentStockItemSubquery}` + buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]) ]); return ` diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index e37d84b..74c1359 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -693,6 +693,9 @@ function shouldRestoreInventoryRootFrame( if (!canReenterInventoryRoot) { return false; } + if (intent === "inventory_aging_by_purchase_date") { + return false; + } if (intent !== "unknown" && !isInventoryIntent(intent) && !hasInventoryRootRestatementCue) { return false; } @@ -723,20 +726,20 @@ function shouldRestoreInventoryRootFrame( function hasSelectedObjectInventorySignal(text: string): boolean { const repairedSelectedObjectText = textWithRepairedVariant(String(text ?? "")); if ( - /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( + /(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( repairedSelectedObjectText ) ) { return true; } if ( - /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( + /(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( String(text ?? "") ) ) { return true; } - return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( + return /(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( String(text ?? "") ); } @@ -775,6 +778,7 @@ export function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean /(?:по\s+каким\s+документам\s+(?:это|его|ее|её|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+(?:это|его|ее|её|она|он|этот\s+товар|эта\s+позиция|эту\s+позицию)?\s*(?:подтвержден[оа]?|подтверждена\s+закупка|доказан[оа]?|закрыт[оа]?)|закупк[аи]\s+подтвержден[аы]?\s+документ|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему))/iu.test( value ) || + /(?:документ[а-яё]*[\s\S]{0,40}поступлен[а-яё]*[\s\S]{0,140}(?:выбранн|товар|позици)|какие\s+документ[а-яё]*[\s\S]{0,40}поступлен[а-яё]*[\s\S]{0,140}(?:выбранн|товар|позици))/iu.test(value) || /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+(?:это|его|её|ее|она|он|этот\s+товар|эта\s+позиция|эту\s+позицию)?\s*(?:подтвержден[оа]?|доказан[оа]?|закрыт[оа]?)|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test( value ) || @@ -1062,6 +1066,22 @@ function mergeFollowupFilters( reasons.push("item_cleared_for_stock_slice_aging"); } }; + const clearInventoryOnHandWarehouseAliasItem = (): void => { + if (intent !== "inventory_on_hand_as_of_date") { + return; + } + const currentItem = toNonEmptyString(merged.item); + const currentWarehouse = toNonEmptyString(merged.warehouse); + if (!currentItem || !currentWarehouse) { + return; + } + const normalizedItem = normalizeOrganizationScopeSearchText(currentItem); + const normalizedWarehouse = normalizeOrganizationScopeSearchText(currentWarehouse); + if (normalizedItem && normalizedWarehouse && normalizedItem === normalizedWarehouse) { + delete merged.item; + reasons.push("item_cleared_as_warehouse_scope_alias_for_inventory_snapshot"); + } + }; if (!followupContext) { if ( (intent === "list_open_contracts" || @@ -1081,18 +1101,21 @@ function mergeFollowupFilters( ); } } + clearInventoryOnHandWarehouseAliasItem(); clearInventoryAgingOrganizationAliasItem(null); return { filters: merged, reasons }; } const previous = followupContext.previous_filters ?? {}; + const root = followupContext.root_filters ?? {}; const previousAnchorValue = toNonEmptyString(followupContext.previous_anchor_value); const previousCounterparty = toNonEmptyString(previous.counterparty); const previousContract = toNonEmptyString(previous.contract); const previousAccount = toNonEmptyString(previous.account); const previousItem = toNonEmptyString(previous.item); const previousAnchorItem = followupContext.previous_anchor_type === "item" ? previousAnchorValue : null; - const previousOrganization = toNonEmptyString(previous.organization); + const previousOrganization = toNonEmptyString(previous.organization) ?? toNonEmptyString(root.organization); + const previousWarehouse = toNonEmptyString(previous.warehouse) ?? toNonEmptyString(root.warehouse); const previousAsOfDate = toNonEmptyString(previous.as_of_date); const previousPeriodFrom = toNonEmptyString(previous.period_from); const previousPeriodTo = toNonEmptyString(previous.period_to); @@ -1539,6 +1562,10 @@ function mergeFollowupFilters( reasons.push("period_derived_from_inventory_root_frame_year"); } if (intent === "inventory_aging_by_purchase_date") { + if (previousWarehouse && !toNonEmptyString(merged.warehouse)) { + merged.warehouse = previousWarehouse; + reasons.push("warehouse_from_followup_context"); + } clearInventoryAgingOrganizationAliasItem(previousOrganization); } if ( @@ -1565,6 +1592,7 @@ function mergeFollowupFilters( delete merged.period_to; reasons.push("period_cleared_by_all_time_followup"); } + clearInventoryOnHandWarehouseAliasItem(); return { filters: merged, reasons }; } @@ -1755,6 +1783,7 @@ function mergeFollowupFilters( } } + clearInventoryOnHandWarehouseAliasItem(); return { filters: merged, reasons }; } @@ -1788,18 +1817,56 @@ function deriveIntentWithFollowupContext( userMessage: string, followupContext: AddressFollowupContext | null ): AddressIntentResolution { + const normalizedMessage = String(userMessage ?? ""); + const hasSelectedObjectReference = hasSelectedObjectInventorySignal(normalizedMessage); if (!followupContext || (!followupContext.previous_intent && !followupContext.target_intent)) { + if (hasSelectedObjectReference && hasInventoryPurchaseDocumentsFollowupCue(normalizedMessage)) { + if ( + detectedIntent.intent === "unknown" || + detectedIntent.intent === "list_documents_by_counterparty" || + detectedIntent.intent === "list_documents_by_contract" || + detectedIntent.intent === "inventory_on_hand_as_of_date" + ) { + return { + intent: "inventory_purchase_documents_for_item", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_documents"] + }; + } + } + if (hasSelectedObjectReference && hasInventorySupplierFollowupCue(normalizedMessage)) { + if ( + detectedIntent.intent === "unknown" || + detectedIntent.intent === "list_documents_by_counterparty" || + detectedIntent.intent === "list_documents_by_contract" || + detectedIntent.intent === "inventory_on_hand_as_of_date" + ) { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_supplier"] + }; + } + } + if (hasSelectedObjectReference && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) { + if (detectedIntent.intent === "unknown" || detectedIntent.intent === "inventory_on_hand_as_of_date") { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_purchase_date"] + }; + } + } return detectedIntent; } - const normalizedMessage = String(userMessage ?? ""); const genericFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage); const previousFilters = followupContext.previous_filters ?? {}; const purchaseBridgeContinuationSignal = followupContext.previous_intent === "vat_liability_confirmed_for_tax_period" && Boolean(toNonEmptyString(previousFilters.purchase_date_bridge_selected)) && hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage); - const hasFollowupSignal = genericFollowupSignal || purchaseBridgeContinuationSignal; + const hasFollowupSignal = genericFollowupSignal || purchaseBridgeContinuationSignal || hasSelectedObjectReference; if (!hasFollowupSignal) { return detectedIntent; } @@ -1834,7 +1901,6 @@ function deriveIntentWithFollowupContext( followupContext.root_anchor_type === "item" || followupContext.current_frame_kind === "inventory_root" || followupContext.current_frame_kind === "inventory_drilldown"; - const hasSelectedObjectReference = hasSelectedObjectInventorySignal(normalizedMessage); const inventorySelectedObjectFollowup = inventoryLineageActive && (hasSelectedObjectReference || (previousIsInventoryFamily && hasFollowupSignal)); const previousCounterpartyLaneActive = diff --git a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts index 4511a02..76ad5be 100644 --- a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts @@ -14,7 +14,11 @@ import type { ComposeStageRow } from "./composeStage"; interface InventoryComposeOptions { userMessage?: string; + rawUserMessage?: string; itemHint?: string; + counterpartyHint?: string; + accountHint?: string; + warehouseHint?: string; asOfDate?: string; periodFrom?: string; periodTo?: string; @@ -134,6 +138,60 @@ function inventoryPartyListOrUnknown(parties: string[]): string { return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем"; } +function inventoryPartyLeadList(parties: string[], limit = 6): string { + const visible = parties.slice(0, limit); + const remaining = parties.length - visible.length; + const suffix = remaining > 0 ? `; и еще ${remaining}` : ""; + return `${visible.join("; ")}${suffix}`; +} + +function inventoryItemLeadList(items: string[], limit = 6): string { + const visible = items.slice(0, limit); + const remaining = items.length - visible.length; + const suffix = remaining > 0 ? `; и еще ${remaining}` : ""; + return `${visible.join("; ")}${suffix}`; +} + +function collectInventoryTraceItems(rows: ComposeStageRow[]): string[] { + const result: string[] = []; + const seen = new Set(); + for (const row of rows) { + const item = String(row.item ?? "").trim(); + if (!item) { + continue; + } + const comparable = normalizeInventoryReplyEntityToken(item); + if (!comparable || seen.has(comparable)) { + continue; + } + seen.add(comparable); + result.push(item); + } + return result; +} + +function extractInventoryCounterpartiesFromEvidenceLines(lines: string[]): string[] { + const counterparties: string[] = []; + for (const line of lines) { + const match = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|]+)/iu.exec(line); + const value = String(match?.[1] ?? "").replace(/\s+/gu, " ").trim(); + if (value) { + counterparties.push(value); + } + } + return counterparties; +} + +function hasSupplierScopedItemListQuestion(options: InventoryComposeOptions): boolean { + if (!String(options.counterpartyHint ?? "").trim()) { + return false; + } + const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`; + return /(?:какие?\s+товар|товар(?:ы|ов)?\s+(?:от|у)\s+поставщик|позици(?:и|я|ю)|номенклатур|лежат|остат(?:ок|ки|ка|ков))/iu.test( + text + ); +} + function omitAmountFromInventoryTraceEvidence(lines: string[]): string[] { return lines.map((line) => String(line ?? "").replace(/\s+\|\s+сумма:\s+[^|]+(?=\s+\||$)/gu, "")); } @@ -216,6 +274,43 @@ function sumInventoryRowQuantity(rows: ComposeStageRow[]): number { return rows.reduce((sum, row) => sum + (typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : 0), 0); } +function cleanInventoryScopeHint(value: string | null | undefined): string | null { + const cleaned = String(value ?? "").replace(/\s+/gu, " ").trim(); + return cleaned.length > 0 ? cleaned : null; +} + +function inventoryOnHandAccountScopeLabel(options: InventoryComposeOptions): string | null { + const accountHint = cleanInventoryScopeHint(options.accountHint); + if (accountHint) { + return `по счету ${accountHint}`; + } + const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`; + return /(?:^|[^\d])41(?:[.,]\d+)?(?:[^\d]|$)|41\s*сч[её]т/iu.test(text) ? "по счету 41" : null; +} + +function inventoryOnHandWarehouseScopeLabel( + options: InventoryComposeOptions, + uniqueWarehouses: string[] +): string | null { + const warehouseHint = cleanInventoryScopeHint(options.warehouseHint); + if (warehouseHint) { + return `по складу ${warehouseHint}`; + } + const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`; + if (uniqueWarehouses.length === 1 && /(?:по|на)\s+склад[уе]?/iu.test(text)) { + return `по складу ${uniqueWarehouses[0]}`; + } + return null; +} + +function inventoryOnHandScopePhrase(options: InventoryComposeOptions, uniqueWarehouses: string[]): string { + const scopes = [ + inventoryOnHandAccountScopeLabel(options), + inventoryOnHandWarehouseScopeLabel(options, uniqueWarehouses) + ].filter((item): item is string => Boolean(item)); + return scopes.length > 0 ? scopes.join(" и ") : "на складе"; +} + function formatInventoryPercent(value: number | null, formatNumberWithDots: (value: number, fractionDigits?: number) => string): string { return value === null || !Number.isFinite(value) ? "не подтверждена" : `${formatNumberWithDots(value, 2)}%`; } @@ -354,10 +449,11 @@ export function composeInventoryReply( positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0) ); const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0); + const scopePhrase = inventoryOnHandScopePhrase(options, uniqueWarehouses); const directAnswerLine = positions.length > 0 - ? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.` - : `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`; + ? `На ${deps.formatDateRu(asOfDate)} ${scopePhrase} подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.` + : `На ${deps.formatDateRu(asOfDate)} ${scopePhrase} подтвержденных товарных остатков не найдено.`; const lines: string[] = [directAnswerLine]; if (positions.length > 0) { @@ -392,13 +488,22 @@ export function composeInventoryReply( ]); } - appendInventoryBulletSection(lines, "Сводка:", [ + const summaryBullets = [ `Дата среза: ${deps.formatDateRu(asOfDate)}.`, `Позиции с остатком: ${deps.formatNumberWithDots(positions.length)}.`, `Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`, `Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`, "Общее количество не свожу в один управленческий показатель, потому что в остатках смешаны разнородные позиции." - ]); + ]; + const accountScope = inventoryOnHandAccountScopeLabel(options); + const warehouseScope = inventoryOnHandWarehouseScopeLabel(options, uniqueWarehouses); + if (accountScope) { + summaryBullets.splice(1, 0, `Срез ${accountScope}.`); + } + if (warehouseScope) { + summaryBullets.splice(accountScope ? 2 : 1, 0, `Срез ${warehouseScope}.`); + } + appendInventoryBulletSection(lines, "Сводка:", summaryBullets); if (rows.length !== positions.length) { lines.push(`- Проверенных строк движения: ${deps.formatNumberWithDots(rows.length)}.`); } @@ -561,22 +666,50 @@ export function composeInventoryReply( const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); const summary = deps.summarizeInventoryTraceRows(purchaseRows); normalizeInventoryTraceSummaryCounterparties(summary); - const unresolvedRows = purchaseRows.filter((row) => deps.extractInventoryCounterpartyCandidates(row).length === 0); + const purchaseRowCounterparties = purchaseRows.map((row) => + deps.uniqueStrings([ + ...deps.extractInventoryCounterpartyCandidates(row), + ...extractInventoryCounterpartiesFromEvidenceLines(deps.formatInventoryTraceRows([row], 1)) + ]) + ); + const observedCounterparties = deps.uniqueStrings([ + ...summary.counterparties, + ...purchaseRowCounterparties.flat() + ]); + const unresolvedRows = purchaseRows.filter((_, index) => (purchaseRowCounterparties[index]?.length ?? 0) === 0); + const unresolvedSupplierQuestionText = `${String(options.userMessage ?? "")}\n${String(options.rawUserMessage ?? "")}`; const unresolvedSupplierQuestion = /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test( - String(options.userMessage ?? "") + unresolvedSupplierQuestionText ); if (unresolvedSupplierQuestion) { + const unresolvedItems = collectInventoryTraceItems(unresolvedRows); + const unresolvedItemPreviewLimit = 3; + const unresolvedDocumentPreviewLimit = 2; const directAnswerLine = unresolvedRows.length > 0 - ? `В текущем складском срезе найдено операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.` + ? unresolvedItems.length > 0 + ? `В текущем складском срезе на ${deps.formatDateRu(asOfDate)} найдено ${deps.formatNumberWithDots(unresolvedItems.length)} позиций без надежной привязки к поставщику; первые примеры ниже.` + : `В текущем складском срезе на ${deps.formatDateRu(asOfDate)} найдено операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.` : "В текущем складском срезе товары без явно выделенной привязки к поставщику в доступных данных не найдены."; const lines: string[] = [directAnswerLine]; + if (unresolvedItems.length > 0) { + appendInventorySection( + lines, + "Позиции без надежной привязки к поставщику:", + unresolvedItems.slice(0, unresolvedItemPreviewLimit).map((item, index) => `${index + 1}. ${item}`) + ); + if (unresolvedItems.length > unresolvedItemPreviewLimit) { + lines.push( + `- Показаны первые ${unresolvedItemPreviewLimit} из ${deps.formatNumberWithDots(unresolvedItems.length)} таких позиций.` + ); + } + } appendInventoryBulletSection(lines, "Что проверили:", [ `Дата среза: ${deps.formatDateRu(asOfDate)}.`, `Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`, `Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`, - `Поставщиков, выделенных в остальных операциях: ${deps.formatNumberWithDots(summary.counterparties.length)}.` + `Поставщиков, выделенных в остальных операциях: ${deps.formatNumberWithDots(observedCounterparties.length)}.` ]); appendInventoryBulletSection(lines, "Ограничения:", [ "Без партионного учета это проверка доступного закупочного следа по складскому срезу, а не юридическое доказательство владельца каждой партии." @@ -584,11 +717,16 @@ export function composeInventoryReply( if (unresolvedRows.length > 0) { appendInventorySection( lines, - "Позиции без явно выделенного поставщика:", - deps.formatInventoryTraceRows(unresolvedRows, 12) + "Опорные документы:", + deps.formatInventoryTraceRows(unresolvedRows, unresolvedDocumentPreviewLimit) ); - } else if (summary.counterparties.length > 0) { - lines.push(`- В доступном закупочном следе встречаются поставщики: ${summary.counterparties.slice(0, 6).join("; ")}.`); + if (unresolvedRows.length > unresolvedDocumentPreviewLimit) { + lines.push( + `- Показаны первые ${unresolvedDocumentPreviewLimit} из ${deps.formatNumberWithDots(unresolvedRows.length)} строк без выделенного поставщика.` + ); + } + } else if (observedCounterparties.length > 0) { + lines.push(`- В доступном закупочном следе встречаются поставщики: ${observedCounterparties.slice(0, 6).join("; ")}.`); } return buildFactualSummaryReply( lines, @@ -596,35 +734,100 @@ export function composeInventoryReply( ); } const warehouseLabel = summary.warehouses[0] ?? "не указанного склада"; + const purchasePeriodLabel = + summary.firstPeriod && summary.lastPeriod + ? `${deps.inventoryTraceDateLabel(summary.firstPeriod)}..${deps.inventoryTraceDateLabel(summary.lastPeriod)}` + : "даты закупки не выделены"; + const supplierScopedItems = collectInventoryTraceItems(purchaseRows); + const supplierScopedItemQuestion = hasSupplierScopedItemListQuestion(options); + if (supplierScopedItemQuestion) { + const requestedSupplierLabel = summary.counterparties[0] ?? String(options.counterpartyHint ?? "").trim(); + const directAnswerLine = + supplierScopedItems.length > 0 + ? `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} подтверждены позиции: ${inventoryItemLeadList(supplierScopedItems)}.` + : `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} позиции не подтверждены в доступном закупочном следе.`; + const lines: string[] = [directAnswerLine]; + if (supplierScopedItems.length > 0) { + appendInventorySection( + lines, + "Позиции:", + supplierScopedItems.slice(0, 8).map((item, index) => `${index + 1}. ${item}`) + ); + if (supplierScopedItems.length > 8) { + lines.push(`- Показаны первые 8 из ${deps.formatNumberWithDots(supplierScopedItems.length)} позиций.`); + } + } + appendInventoryBulletSection(lines, "Что проверили:", [ + `Дата среза: ${deps.formatDateRu(asOfDate)}.`, + `Период найденного закупочного следа: ${purchasePeriodLabel}.`, + `Закупочных документов / операций в выборке: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.` + ]); + appendInventoryBulletSection(lines, "Ограничения:", [ + "Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии." + ]); + if (observedCounterparties.length > 0) { + lines.push(`- Найденные поставщики: ${inventoryPartyLeadList(observedCounterparties)}.`); + } + if (purchaseRows.length > 0) { + appendInventorySection( + lines, + "Опорные документы:", + deps.formatInventoryTraceRows(purchaseRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) + ); + if (purchaseRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) { + lines.push( + `- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(purchaseRows.length)} найденных строк; полный след остается в подтвержденном срезе.` + ); + } + } + return buildFactualSummaryReply( + lines, + buildConfirmedBalanceSemantics( + purchaseRows.length > 0 ? (supplierScopedItems.length > 0 ? "strong" : "medium") : "medium", + purchaseRows.length > 0 + ) + ); + } const directAnswerLine = - summary.counterparties.length === 1 - ? `По складскому остатку ${warehouseLabel} выявлен поставщик: ${summary.counterparties[0]}.` - : summary.counterparties.length > 1 - ? `По складскому остатку ${warehouseLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 6).join("; ")}.` - : `По складскому остатку ${warehouseLabel} поставщик в доступных данных не выделен.`; + observedCounterparties.length === 1 + ? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} выявлен поставщик закупочного следа: ${observedCounterparties[0]}.` + : observedCounterparties.length > 1 + ? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} найдено несколько поставщиков закупочного следа: ${inventoryPartyLeadList(observedCounterparties)}.` + : unresolvedRows.length > 0 + ? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} однозначный поставщик текущего остатка не подтвержден: в ${deps.formatNumberWithDots(unresolvedRows.length)} закупочных операциях поставщик не выделен.` + : `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} поставщик в доступных данных не выделен.`; const lines: string[] = [directAnswerLine]; + if (unresolvedRows.length > 0) { + lines.push( + `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} однозначная атрибуция части текущего остатка не подтверждена: ${deps.formatNumberWithDots(unresolvedRows.length)} закупочных операций без явно выделенного поставщика.` + ); + } appendInventoryBulletSection(lines, "Что проверили:", [ `Дата среза: ${deps.formatDateRu(asOfDate)}.`, - `Первая найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`, - `Последняя найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`, - `Закупочных документов в выборке: ${deps.formatNumberWithDots(summary.documents.length)}.`, - `Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.` + `Период найденного закупочного следа: ${purchasePeriodLabel}.`, + `Закупочных документов / операций в выборке: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.` ]); appendInventoryBulletSection(lines, "Ограничения:", [ "Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии." ]); - if (summary.counterparties.length > 0) { - lines.push(`- Найденные поставщики: ${summary.counterparties.slice(0, 6).join("; ")}.`); + if (observedCounterparties.length > 0) { + lines.push(`- Найденные поставщики: ${inventoryPartyLeadList(observedCounterparties)}.`); } else if (purchaseRows.length > 0) { lines.push("- Закупочные движения найдены, но поставщик не выделен отдельным полем в доступных данных."); } else { lines.push("- В доступных данных не найдено закупочных движений по выбранному складскому срезу."); } - if (unresolvedRows.length > 0) { - lines.push(`- Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`); - } if (purchaseRows.length > 0) { - appendInventorySection(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, 10)); + appendInventorySection( + lines, + "Опорные документы:", + deps.formatInventoryTraceRows(purchaseRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) + ); + if (purchaseRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) { + lines.push( + `- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(purchaseRows.length)} найденных строк; полный след остается в подтвержденном срезе.` + ); + } } return buildFactualSummaryReply( lines, @@ -1025,12 +1228,16 @@ export function composeInventoryReply( const supplierMatches = inventoryRequestedPartyMatches(requestedSupplier, purchaseSummary.counterparties); const buyerMatches = inventoryRequestedPartyMatches(requestedBuyer, saleSummary.counterparties); const mismatchParts: string[] = []; - if (requestedSupplier && purchaseRows.length > 0 && !supplierMatches) { + if (requestedSupplier && purchaseRows.length === 0) { + mismatchParts.push(`закупка у запрошенного поставщика ${requestedSupplier} не найдена`); + } else if (requestedSupplier && purchaseRows.length > 0 && !supplierMatches) { mismatchParts.push( `запрошенный поставщик ${requestedSupplier} не совпал с найденным поставщиком: ${inventoryPartyListOrUnknown(purchaseSummary.counterparties)}` ); } - if (requestedBuyer && saleRows.length > 0 && !buyerMatches) { + if (requestedBuyer && saleRows.length === 0) { + mismatchParts.push(`продажа/выбытие запрошенному покупателю ${requestedBuyer} не найдены`); + } else if (requestedBuyer && saleRows.length > 0 && !buyerMatches) { mismatchParts.push( `запрошенный покупатель ${requestedBuyer} не совпал с найденным покупателем: ${inventoryPartyListOrUnknown(saleSummary.counterparties)}` ); @@ -1038,9 +1245,15 @@ export function composeInventoryReply( const directAnswerLine = mismatchParts.length > 0 ? `Запрошенная цепочка по товару ${itemLabel} полностью не подтверждена: ${mismatchParts.join("; ")}.` - : purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1 + : purchaseRows.length > 0 && saleRows.length > 0 && purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1 ? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.` - : `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`; + : purchaseRows.length > 0 && saleRows.length > 0 + ? `По товару ${itemLabel} найдены обе стороны цепочки поставки и продажи; участников несколько или они неоднозначны, детали ниже.` + : purchaseRows.length > 0 + ? `По товару ${itemLabel} закупочная часть цепочки подтверждена, но продажа/выбытие со счета 41.01 в доступных данных не найдены.` + : saleRows.length > 0 + ? `По товару ${itemLabel} найдена продажа/выбытие со счета 41.01, но закупочная часть цепочки в доступных данных не подтверждена.` + : `По товару ${itemLabel} цепочка поставки и продажи не подтверждена: движений по счету 41.01 в доступных данных не найдено.`; const lines: string[] = [directAnswerLine, "", "Подтверждение:"]; lines.push(`- Строк закупки на 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`); lines.push(`- Строк продажи со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`); diff --git a/llm_normalizer/backend/src/services/address_runtime/replyPackaging.ts b/llm_normalizer/backend/src/services/address_runtime/replyPackaging.ts index 12d30a9..fa21ac0 100644 --- a/llm_normalizer/backend/src/services/address_runtime/replyPackaging.ts +++ b/llm_normalizer/backend/src/services/address_runtime/replyPackaging.ts @@ -7,6 +7,7 @@ export interface ComposeFactualReplyOptions { counterpartyHint?: string; organizationHint?: string; accountHint?: string; + warehouseHint?: string; periodFrom?: string; periodTo?: string; asOfDate?: string; diff --git a/llm_normalizer/backend/src/services/address_runtime/semanticHintOverlay.ts b/llm_normalizer/backend/src/services/address_runtime/semanticHintOverlay.ts index 481ffe4..6837b0b 100644 --- a/llm_normalizer/backend/src/services/address_runtime/semanticHintOverlay.ts +++ b/llm_normalizer/backend/src/services/address_runtime/semanticHintOverlay.ts @@ -4,7 +4,11 @@ import type { AddressLlmSemanticHints, AddressSemanticFrame } from "../../types/addressQuery"; -import { isInventoryItemAnchorDegradation, isLowQualityInventoryItemAnchorValue } from "../addressFilterExtractor"; +import { + isInventoryItemAnchorDegradation, + isLowQualityCounterpartyAnchorValue, + isLowQualityInventoryItemAnchorValue +} from "../addressFilterExtractor"; function toNonEmptyString(value: unknown): string | null { if (value === null || value === undefined) { @@ -162,11 +166,16 @@ export function applyAddressLlmSemanticHintsToExtraction( } if (semanticHints.scope_target_kind === "counterparty" && scopeTargetText) { - extractedFilters.counterparty = scopeTargetText; - pushWarning(warnings, "counterparty_from_llm_semantics"); - semanticFrame.scope_kind = "explicit_anchor"; - semanticFrame.anchor_kind = "counterparty"; - semanticFrame.anchor_value = scopeTargetText; + if (isLowQualityCounterpartyAnchorValue(scopeTargetText)) { + delete extractedFilters.counterparty; + pushWarning(warnings, "counterparty_cleared_low_quality_llm_semantics"); + } else { + extractedFilters.counterparty = scopeTargetText; + pushWarning(warnings, "counterparty_from_llm_semantics"); + semanticFrame.scope_kind = "explicit_anchor"; + semanticFrame.anchor_kind = "counterparty"; + semanticFrame.anchor_value = scopeTargetText; + } } if (semanticHints.scope_target_kind === "contract" && scopeTargetText) { @@ -189,6 +198,13 @@ export function applyAddressLlmSemanticHintsToExtraction( pushWarning(warnings, "item_llm_semantics_ignored"); } } + if ( + toNonEmptyString(extractedFilters.counterparty) && + isLowQualityCounterpartyAnchorValue(String(extractedFilters.counterparty)) + ) { + delete extractedFilters.counterparty; + pushWarning(warnings, "counterparty_cleared_low_quality_llm_semantics"); + } return { ...extraction, diff --git a/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts index ef657c6..2e0a4dd 100644 --- a/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts @@ -76,6 +76,222 @@ function normalizeAddressReplyType(value: unknown): AssistantReplyType { return value === "factual" || value === "partial_coverage" ? value : "partial_coverage"; } +function uniqueNormalizedStrings(values: Array): string[] { + const result: string[] = []; + const seen = new Set(); + for (const value of values) { + const text = String(value ?? "").trim(); + if (!text) { + continue; + } + const key = text.toLocaleLowerCase("ru-RU").replace(/ё/g, "е"); + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(text); + } + return result; +} + +function extractInventorySupplierOverlapCounterpartiesFromReply(replyText: string): string[] { + const values: string[] = []; + const pattern = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|\r\n]+)/giu; + for (const match of replyText.matchAll(pattern)) { + const value = toNullableString(match[1]?.replace(/[.;,\s]+$/u, "")); + if (!value || /^(?:\u043d\u0435\s+\u0432\u044b\u0434\u0435\u043b\u0435\u043d|\u043d\/\u0434|unknown)$/iu.test(value)) { + continue; + } + values.push(value); + } + return uniqueNormalizedStrings(values); +} + +function formatInventorySupplierOverlapCounterpartyLead(counterparties: string[], limit = 6): string { + const visible = counterparties.slice(0, limit); + const remaining = counterparties.length - visible.length; + const suffix = remaining > 0 ? `; и еще ${remaining}` : ""; + return `${visible.join("; ")}${suffix}`; +} + +function hasInventorySupplierOverlapDefensiveLead(firstLine: string): boolean { + const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435"); + const startsWithStockScope = /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test( + normalized + ); + const hasDefensiveAttribution = /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test( + normalized + ); + return startsWithStockScope && hasDefensiveAttribution; +} + +function firstNonEmptyReplyLine(text: unknown): string { + return String(text ?? "") + .split(/\r?\n/u) + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? ""; +} + +function hasInventorySupplierOverlapCounterpartyLead(firstLine: string): boolean { + const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435"); + return ( + /^\u043f\u043e\s+\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443/u.test(normalized) && + /\u043d\u0430\u0439\u0434\u0435\u043d/u.test(normalized) && + /\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a/u.test(normalized) && + /\u0437\u0430\u043a\u0443\u043f\u043e\u0447\u043d\u043e\u0433\u043e\s+\u0441\u043b\u0435\u0434\u0430/u.test(normalized) + ); +} + +function preserveInventorySupplierOverlapCounterpartyLeadAfterSanitize(input: { + rawReply: string; + sanitizedReply: string; + addressDebug: unknown; +}): { text: string; applied: boolean } { + const debug = toRecordObject(input.addressDebug); + const detectedIntent = toNullableString(debug?.detected_intent); + if (detectedIntent !== "inventory_supplier_stock_overlap_as_of_date") { + return { text: input.sanitizedReply, applied: false }; + } + const rawLead = firstNonEmptyReplyLine(input.rawReply); + if (!hasInventorySupplierOverlapCounterpartyLead(rawLead)) { + return { text: input.sanitizedReply, applied: false }; + } + const sanitizedLead = firstNonEmptyReplyLine(input.sanitizedReply); + if (hasInventorySupplierOverlapCounterpartyLead(sanitizedLead)) { + return { text: input.sanitizedReply, applied: false }; + } + const sanitizedText = String(input.sanitizedReply ?? "").trim(); + return { + text: sanitizedText ? [rawLead, sanitizedText].join("\n\n") : rawLead, + applied: true + }; +} + +function inspectInventorySupplierOverlapDefensiveLead(firstLine: string): { + stockScopeMatch: boolean; + defensiveMatch: boolean; +} { + const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435"); + return { + stockScopeMatch: /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test( + normalized + ), + defensiveMatch: /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test( + normalized + ) + }; +} + +function buildInventorySupplierOverlapSurfaceRepairAudit(input: { + applied: boolean; + detectedIntent: string | null; + reasonCodes: string[]; + counterparties?: string[]; + firstLineSample?: string; + stockScopeMatch?: boolean; + defensiveMatch?: boolean; +}): Record { + return { + schema_version: "inventory_supplier_overlap_surface_repair_v1", + applied: input.applied, + detected_intent: input.detectedIntent, + counterparties: input.counterparties ?? [], + first_line_sample: input.firstLineSample ?? null, + stock_scope_match: input.stockScopeMatch ?? null, + defensive_match: input.defensiveMatch ?? null, + reason_codes: input.reasonCodes + }; +} + +function repairInventorySupplierOverlapSurfaceReply( + replyText: unknown, + addressDebug: unknown +): { text: string; audit: Record | null } { + const text = String(replyText ?? ""); + const debug = toRecordObject(addressDebug); + const detectedIntent = toNullableString(debug?.detected_intent); + if (detectedIntent !== "inventory_supplier_stock_overlap_as_of_date") { + return { + text, + audit: buildInventorySupplierOverlapSurfaceRepairAudit({ + applied: false, + detectedIntent, + reasonCodes: ["inventory_supplier_overlap_surface_repair_intent_not_applicable"] + }) + }; + } + const lines = text.split(/\r?\n/u); + const firstLine = lines[0]?.trim() ?? ""; + const leadInspection = inspectInventorySupplierOverlapDefensiveLead(firstLine); + if (!hasInventorySupplierOverlapDefensiveLead(firstLine)) { + return { + text, + audit: buildInventorySupplierOverlapSurfaceRepairAudit({ + applied: false, + detectedIntent, + firstLineSample: firstLine.slice(0, 220), + stockScopeMatch: leadInspection.stockScopeMatch, + defensiveMatch: leadInspection.defensiveMatch, + reasonCodes: ["inventory_supplier_overlap_surface_repair_first_line_not_defensive"] + }) + }; + } + const counterparties = extractInventorySupplierOverlapCounterpartiesFromReply(text); + if (counterparties.length === 0) { + return { + text, + audit: buildInventorySupplierOverlapSurfaceRepairAudit({ + applied: false, + detectedIntent, + counterparties, + firstLineSample: firstLine.slice(0, 220), + stockScopeMatch: leadInspection.stockScopeMatch, + defensiveMatch: leadInspection.defensiveMatch, + reasonCodes: ["inventory_supplier_overlap_surface_repair_no_counterparties"] + }) + }; + } + const repairedLead = + counterparties.length === 1 + ? `По складскому остатку найден поставщик закупочного следа: ${counterparties[0]}.` + : `По складскому остатку найдено несколько поставщиков закупочного следа: ${formatInventorySupplierOverlapCounterpartyLead(counterparties)}.`; + return { + text: [repairedLead, firstLine, ...lines.slice(1)].join("\n"), + audit: buildInventorySupplierOverlapSurfaceRepairAudit({ + applied: true, + detectedIntent, + counterparties, + firstLineSample: firstLine.slice(0, 220), + stockScopeMatch: leadInspection.stockScopeMatch, + defensiveMatch: leadInspection.defensiveMatch, + reasonCodes: ["inventory_supplier_overlap_surface_repair_applied"] + }) + }; +} + +function extractSingleWarehouseScopeFromReply(replyText: string): string | null { + const values: string[] = []; + const normalized = String(replyText ?? ""); + const byFieldPattern = /(?:^|\|)\s*склад\s*:\s*([^|\r\n]+)/giu; + for (const match of normalized.matchAll(byFieldPattern)) { + const value = toNullableString(match[1]?.replace(/[.;,\s]+$/u, "")); + if (value) { + values.push(value); + } + } + const fieldUnique = uniqueNormalizedStrings(values); + if (fieldUnique.length > 0) { + return fieldUnique.length === 1 ? fieldUnique[0] : null; + } + const leadMatch = normalized.match(/(?:по|на)\s+склад[уе]?\s+([^.,;\r\n]+)/iu); + const leadWarehouse = toNullableString(leadMatch?.[1]?.replace(/[.;,\s]+$/u, "")); + if (leadWarehouse) { + values.push(leadWarehouse); + } + const unique = uniqueNormalizedStrings(values); + return unique.length === 1 ? unique[0] : null; +} + function sameBusinessLabel(left: unknown, right: unknown): boolean { const normalizedLeft = toNullableString(left)?.toLocaleLowerCase("ru-RU").replace(/ё/g, "е"); const normalizedRight = toNullableString(right)?.toLocaleLowerCase("ru-RU").replace(/ё/g, "е"); @@ -115,7 +331,10 @@ function cleanComparisonScopeCompanyLine(line: string, organization: string | nu .replace(/\bcompany-level\b/giu, "общий по компании") .replace(/\breusable bundle\b/giu, "сохраненный подтвержденный срез"); if (organization) { - clean = clean.replace(/по компании\s+Альтернатива Плюс/iu, `по компании ${organization}`); + clean = clean.replace( + /по компании\s+.+?(?=\s+(?:подтвержден|подтвержд[её]н|зафиксирован|показан|есть)\b|:|$)/iu, + `по компании ${organization}` + ); } return clean.trim(); } @@ -136,9 +355,7 @@ function buildComparisonScopeProofReply(input: { session: unknown; userMessage?: unknown; }): { reply: string; audit: Record } | null { - if (!hasComparisonScopeProofCue(input.userMessage)) { - return null; - } + const legalOrganizationFromCurrentTurn = legalOrganizationLabelFromClarification(input.userMessage); const entryPoint = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1); const turnInput = toRecordObject(entryPoint?.turn_input); const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref); @@ -146,6 +363,9 @@ function buildComparisonScopeProofReply(input: { if (!isBusinessOverview) { return null; } + if (!hasComparisonScopeProofCue(input.userMessage) && !legalOrganizationFromCurrentTurn) { + return null; + } const separateCandidates = Array.isArray(turnMeaning?.business_overview_separate_entity_candidates) ? turnMeaning.business_overview_separate_entity_candidates : []; @@ -172,7 +392,7 @@ function buildComparisonScopeProofReply(input: { if (!incomingAmount && !outgoingAmount && !netAmount) { return null; } - const organization = legalOrganizationLabelFromClarification(input.userMessage) + const organization = legalOrganizationFromCurrentTurn ?? toNullableString(turnMeaning?.explicit_organization_scope) ?? toNullableString(toRecordObject(comparisonScope?.organization)?.label); const documentCount = Number(toRecordObject(documentBundle)?.document_count); @@ -355,8 +575,24 @@ export function runAssistantAddressLaneResponseRuntime ): RunAssistantAddressLaneResponseRuntimeOutput { const finalizeAddressTurnSafe = input.finalizeAddressTurn ?? finalizeAssistantAddressTurn; - const safeAddressReply = input.sanitizeOutgoingAssistantText(input.addressLane.reply_text); const debug = input.buildAddressDebugPayload(input.addressLane.debug, input.llmPreDecomposeMeta); + const surfaceRepair = repairInventorySupplierOverlapSurfaceReply( + input.addressLane.reply_text, + debug + ); + const safeAddressReplyBase = input.sanitizeOutgoingAssistantText(surfaceRepair.text); + const preservedAddressReply = preserveInventorySupplierOverlapCounterpartyLeadAfterSanitize({ + rawReply: surfaceRepair.text, + sanitizedReply: safeAddressReplyBase, + addressDebug: debug + }); + const safeAddressReply = preservedAddressReply.text; + if (surfaceRepair.audit) { + debug.inventory_supplier_overlap_surface_repair_v1 = { + ...surfaceRepair.audit, + lead_preserved_after_sanitize: preservedAddressReply.applied + }; + } const followupOffer = input.buildAddressFollowupOffer(debug); if (followupOffer) { debug.address_followup_offer = followupOffer; @@ -391,14 +627,38 @@ export function runAssistantAddressLaneResponseRuntime) : null; - if (rootIntent || currentFrameKind) { + const detectedIntent = input.toNonEmptyString(debug.detected_intent); + const rootFrameWarehouse = + input.toNonEmptyString(rootFilters?.warehouse) ?? + input.toNonEmptyString(debugFilters?.warehouse) ?? + (detectedIntent === "inventory_on_hand_as_of_date" ? extractSingleWarehouseScopeFromReply(safeAddressReply) : null); + const shouldPersistInventoryRootFrame = + rootIntent || currentFrameKind || detectedIntent === "inventory_on_hand_as_of_date"; + if (shouldPersistInventoryRootFrame) { + const rootFiltersForDebug: Record = { + ...(rootFilters ?? {}), + organization: input.toNonEmptyString(rootFilters?.organization) ?? input.toNonEmptyString(debugFilters?.organization), + warehouse: rootFrameWarehouse, + as_of_date: input.toNonEmptyString(rootFilters?.as_of_date) ?? input.toNonEmptyString(debugFilters?.as_of_date), + period_from: input.toNonEmptyString(rootFilters?.period_from) ?? input.toNonEmptyString(debugFilters?.period_from), + period_to: input.toNonEmptyString(rootFilters?.period_to) ?? input.toNonEmptyString(debugFilters?.period_to) + }; + for (const key of Object.keys(rootFiltersForDebug)) { + if (!input.toNonEmptyString(rootFiltersForDebug[key])) { + delete rootFiltersForDebug[key]; + } + } debug.address_root_frame_context = { - root_intent: rootIntent, - current_frame_kind: currentFrameKind, - organization: input.toNonEmptyString(rootFilters?.organization), - as_of_date: input.toNonEmptyString(rootFilters?.as_of_date), - period_from: input.toNonEmptyString(rootFilters?.period_from), - period_to: input.toNonEmptyString(rootFilters?.period_to) + root_intent: rootIntent ?? (detectedIntent === "inventory_on_hand_as_of_date" ? "inventory_on_hand_as_of_date" : null), + root_filters: rootFiltersForDebug, + root_anchor_type: input.toNonEmptyString(followupContextSource?.root_anchor_type) ?? undefined, + root_anchor_value: input.toNonEmptyString(followupContextSource?.root_anchor_value) ?? undefined, + current_frame_kind: currentFrameKind ?? (detectedIntent === "inventory_on_hand_as_of_date" ? "inventory_root" : null), + organization: input.toNonEmptyString(rootFiltersForDebug.organization), + warehouse: input.toNonEmptyString(rootFiltersForDebug.warehouse), + as_of_date: input.toNonEmptyString(rootFiltersForDebug.as_of_date), + period_from: input.toNonEmptyString(rootFiltersForDebug.period_from), + period_to: input.toNonEmptyString(rootFiltersForDebug.period_to) }; } const debugWithRuntimeContracts = attachAssistantRuntimeContractShadow(debug, { diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index 20cac3e..d34870c 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -1479,9 +1479,23 @@ export function buildInventoryRootFrameFromAddressDebug( } const rootFiltersCandidate = toRecordObject(rootFrameContext?.root_filters); + const rootFrameFiltersCandidate = rootFrameContext + ? { + organization: toNonEmptyString(rootFrameContext.organization), + warehouse: toNonEmptyString(rootFrameContext.warehouse), + as_of_date: toNonEmptyString(rootFrameContext.as_of_date), + period_from: toNonEmptyString(rootFrameContext.period_from), + period_to: toNonEmptyString(rootFrameContext.period_to) + } + : null; + const rootFrameFilters = rootFrameFiltersCandidate + ? Object.fromEntries( + Object.entries(rootFrameFiltersCandidate).filter(([, value]) => Boolean(value)) + ) + : null; const filters = { ...(rootFiltersCandidate ?? {}), - ...(rootFiltersCandidate ? {} : extractedFilters) + ...(rootFiltersCandidate ? {} : rootFrameFilters && Object.keys(rootFrameFilters).length > 0 ? rootFrameFilters : extractedFilters) }; if (!filters.organization) { const organization = readAddressDebugOrganization(debug, toNonEmptyString); diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index 08ba7f8..030861a 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -572,6 +572,37 @@ function hasInventoryMarginRankingAddressReply( ); } +function hasExactInventoryPurchaseToSaleChainAddressReply( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + const source = String(input.currentReplySource ?? input.livingChatSource ?? "").trim().toLowerCase(); + if (source !== "address_query_runtime_v1" && source !== "address_exact" && source !== "address_lane") { + return false; + } + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); + const capabilityId = + toNonEmptyString(input.addressRuntimeMeta?.capability_id) ?? + toNonEmptyString(input.addressRuntimeMeta?.capability_contract_id); + if ( + detectedIntent !== "inventory_purchase_to_sale_chain" && + selectedRecipe !== "address_inventory_purchase_to_sale_chain_v1" && + capabilityId !== "inventory_inventory_purchase_to_sale_chain" + ) { + return false; + } + const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status); + const responseType = toNonEmptyString(input.addressRuntimeMeta?.response_type); + return Boolean(hasConfirmedAddressExecution(input) || mcpCallStatus === "matched_non_empty" || responseType === "FACTUAL_LIST"); +} + function hasValueFlowActionConflictWithDiscoveryTurnMeaning( input: ApplyAssistantMcpDiscoveryResponsePolicyInput, entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null @@ -651,6 +682,16 @@ function hasExactMatchedFactualAddressReply( } if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + const turnMeaning = readDiscoveryTurnMeaning(entryPoint); + const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); + const askedAction = toNonEmptyString(turnMeaning?.asked_action_family); + if ( + detectedIntent === "inventory_supplier_stock_overlap_as_of_date" && + hasConfirmedAddressExecution(input) && + (askedDomain === "entity_resolution" || askedAction === "search_business_entity") + ) { + return true; + } if (!(isMetadataDiscoveryTurn(entryPoint) && isInventoryExactAddressIntent(detectedIntent))) { return false; } @@ -916,6 +957,10 @@ export function applyAssistantMcpDiscoveryResponsePolicy( const exactBankOperationsAddressReply = hasExactBankOperationsAddressReply(input, entryPoint); const exactDocumentListAddressReply = hasExactDocumentListAddressReply(input, entryPoint); const inventoryMarginRankingAddressReply = hasInventoryMarginRankingAddressReply(input, entryPoint); + const exactInventoryPurchaseToSaleChainAddressReply = hasExactInventoryPurchaseToSaleChainAddressReply( + input, + entryPoint + ); const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint); const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint); const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning( @@ -930,6 +975,10 @@ export function applyAssistantMcpDiscoveryResponsePolicy( exactBankOperationsAddressReply && !semanticConflictWithDiscoveryTurnMeaning && !valueFlowActionConflictWithDiscoveryTurnMeaning; + const exactInventoryChainProtectsCurrent = + exactInventoryPurchaseToSaleChainAddressReply && + candidate.candidate_status === "clarification_candidate" && + valueFlowActionConflictWithDiscoveryTurnMeaning; if (!entryPoint) { pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); @@ -1009,6 +1058,12 @@ export function applyAssistantMcpDiscoveryResponsePolicy( if (inventoryMarginRankingAddressReply) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_inventory_margin_ranking_address_reply"); } + if (exactInventoryChainProtectsCurrent) { + pushReason( + reasonCodes, + "mcp_discovery_response_policy_keep_exact_inventory_chain_reply_over_value_flow_clarification" + ); + } if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") { pushReason( reasonCodes, @@ -1046,6 +1101,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( !exactBankOperationsProtectsCurrent && !exactDocumentListAddressReply && !inventoryMarginRankingAddressReply && + !exactInventoryChainProtectsCurrent && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && candidate.eligible_for_future_hot_runtime && diff --git a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts index 903a367..85be9e2 100644 --- a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts @@ -70,6 +70,13 @@ function detectSupportedIntent(text, deps) { reason: "address_intent_resolver_current_turn_signal" }; } + if (hasInventorySupplierStockOverlapExactSignal(text)) { + return { + intent: "inventory_supplier_stock_overlap_as_of_date", + confidence: "high", + reason: "inventory_supplier_stock_overlap_current_turn_signal" + }; + } if (/(?:\u043a\u0442\u043e\s+\u043d\u0430\u043c(?:\s+\p{L}+){0,4}\s+\u0434\u043e\u043b\u0436|\u043d\u0430\u043c\s+\u043a\u0442\u043e(?:\s+\p{L}+){0,4}\s+\u0434\u043e\u043b\u0436|\u0434\u0435\u0431\u0438\u0442\u043e\u0440|\u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a|\breceivables?\b)/iu.test(text)) { return { intent: "receivables_confirmed_as_of_date", @@ -246,6 +253,30 @@ function hasSelectedObjectInventoryExactSignal(text) { ); } +function hasInventorySupplierStockOverlapExactSignal(text) { + const normalized = String(text ?? ""); + if (!normalized) { + return false; + } + const hasStockScope = + /(?:\u0441\u043a\u043b\u0430\u0434|\u043e\u0441\u0442\u0430\u0442|\u043b\u0435\u0436\p{L}*|\u043d\u0430\u0445\u043e\u0434\p{L}*|\u0447\u0438\u0441\u043b\p{L}*|warehouse|stock|inventory|on\s+hand)/iu.test( + normalized + ); + const hasSupplierCue = + /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\u0441\u0442\u0430\u0432\p{L}*|\u0437\u0430\u043a\u0443\u043f|\u043a\u0443\u043f\p{L}*|\u043f\u0440\u0438\u043e\u0431\u0440\p{L}*|supplier|vendor|purchase|bought|supplied|procurement)/iu.test( + normalized + ); + const hasOverlapAction = + /(?:\u0443\s+\u043a\u0430\u043a\u043e\u0433\u043e\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\s+\u043a\u0430\u043a\u043e\u043c\u0443\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u0442\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u043f\u0440\u0435\u0434\u0435\u043b\p{L}*[\s\S]{0,80}\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0430\u0439\u0442\p{L}*[\s\S]{0,80}\u0442\u043e\u0432\u0430\u0440|\u043a\u0430\u043a\u0438\u0435\s+\u0442\u043e\u0432\u0430\u0440|supplier[\s\S]{0,80}(?:stock|inventory)|(?:stock|inventory)[\s\S]{0,80}supplier)/iu.test( + normalized + ); + const hasSupplierIdentityAsk = + /(?:\u043a\u0430\u043a\u043e(?:\u0433\u043e|\u043c\u0443)\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u043f\u0440\u0435\u0434\u0435\u043b\p{L}*\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|which\s+supplier|what\s+supplier)/iu.test( + normalized + ); + return hasStockScope && hasSupplierCue && (hasOverlapAction || hasSupplierIdentityAsk); +} + function hasOrganizationLevelEarningsOverviewSignal(text) { const normalized = String(text ?? ""); if (!normalized || hasExplicitCounterpartyValueObject(normalized)) { @@ -508,13 +539,17 @@ export function createAssistantTurnMeaningPolicy(deps = {}) { const counterpartyBidirectionalValueFlow = compactOrganizationCashflowDisplay ? null : detectCounterpartyBidirectionalValueFlowFamily(joinedText); const counterpartyTurnover = compactOrganizationCashflowDisplay ? null : detectCounterpartyTurnoverFamily(joinedText); const selectedObjectInventoryExact = hasSelectedObjectInventoryExactSignal(joinedText); + const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps); + const inventorySupplierStockOverlapExact = + supportedIntent?.intent === "inventory_supplier_stock_overlap_as_of_date" || + llmIntent === "inventory_supplier_stock_overlap_as_of_date" || + hasInventorySupplierStockOverlapExactSignal(joinedText); const broadBusinessEvaluation = compactOrganizationCashflowDisplay ? { family: "broad_business_evaluation" } - : selectedObjectInventoryExact || counterpartyBidirectionalValueFlow?.family + : selectedObjectInventoryExact || inventorySupplierStockOverlapExact || counterpartyBidirectionalValueFlow?.family ? null : detectBroadBusinessEvaluation(joinedText); - const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps); const explicitIntentCandidate = broadBusinessEvaluation?.family ? null @@ -585,8 +620,10 @@ export function createAssistantTurnMeaningPolicy(deps = {}) { ? "confirmed_tax_period" : explicitIntentCandidate === "vat_payable_confirmed_as_of_date" ? "confirmed_snapshot" - : explicitIntentCandidate === "vat_payable_forecast" - ? "forecast" + : explicitIntentCandidate === "vat_payable_forecast" + ? "forecast" + : explicitIntentCandidate === "inventory_supplier_stock_overlap_as_of_date" + ? "supplier_overlap" : explicitIntentCandidate === "list_documents_by_counterparty" ? "list_documents" : counterpartyTurnover?.family diff --git a/llm_normalizer/backend/tests/addressInventoryRootFrameRegression.test.ts b/llm_normalizer/backend/tests/addressInventoryRootFrameRegression.test.ts index 5efe51c..e4fde9c 100644 --- a/llm_normalizer/backend/tests/addressInventoryRootFrameRegression.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryRootFrameRegression.test.ts @@ -36,6 +36,26 @@ describe("inventory root frame regressions", () => { expect(result?.filters.warnings).toContain("period_to_from_followup_context"); }); + it("does not treat an explicit warehouse as an item in root inventory snapshot", () => { + const result = runAddressDecomposeStage( + "Какие конкретно номенклатуры формируют остаток по складу Основной склад на дату 2019-03-31", + null, + { + scope_target_kind: "item", + scope_target_text: "Основной склад", + date_scope_kind: "explicit", + self_scope_detected: false, + selected_object_scope_detected: false + } + ); + + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date"); + expect(result?.filters.extracted_filters.warehouse).toBe("Основной склад"); + expect(result?.filters.extracted_filters.item).toBeUndefined(); + expect(result?.filters.warnings).toContain("item_cleared_as_warehouse_scope_alias_for_inventory_snapshot"); + }); + it("promotes selected-object provenance slang with 'где мы взяли это' into inventory provenance", () => { const result = runAddressDecomposeStage( 'По выбранному объекту "Зеркало для инвалидов поворотное травмобезопасное": где мы взяли это говнище?', @@ -110,6 +130,39 @@ describe("inventory root frame regressions", () => { expect(result?.intent.intent).not.toBe("bank_operations_by_counterparty"); expect(result?.filters.extracted_filters.item).toBe("Четки Пост (84*117)"); }); + + it("routes inline selected-object purchase document wording without external followup context", () => { + const result = runAddressDecomposeStage( + 'По выбранному объекту "Столешница 600*3050*26 дуб ниагара": по каким документам это купили', + null + ); + + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("inventory_purchase_documents_for_item"); + expect(result?.filters.extracted_filters.item).toBe("Столешница 600*3050*26 дуб ниагара"); + }); + + it("routes normalized selected-item purchase document wording from LLM predecompose", () => { + const result = runAddressDecomposeStage( + "Уточнить, какие документы поступления связаны с выбранным товаром «Столешница 600*3050*26 дуб ниагара»", + { + previous_intent: "inventory_purchase_provenance_for_item", + previous_filters: { + item: "Столешница 600*3050*26 дуб ниагара", + as_of_date: "2019-03-31", + organization: 'ООО "Альтернатива Плюс"' + }, + previous_anchor_type: "item", + previous_anchor_value: "Столешница 600*3050*26 дуб ниагара", + root_intent: "inventory_on_hand_as_of_date", + current_frame_kind: "inventory_drilldown" + } + ); + + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("inventory_purchase_documents_for_item"); + expect(result?.filters.extracted_filters.item).toBe("Столешница 600*3050*26 дуб ниагара"); + }); it("restores stock snapshot intent for selected-object restatement on the previously reviewed date", () => { const result = runAddressDecomposeStage( 'По выбранному объекту "Столешница 600*3050*26 альмандин": покажи еще раз остатки на дату которую до этого рассматривали', diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index f04673c..441f4d5 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -3,7 +3,7 @@ import { detectAddressQuestionMode } from "../src/services/addressQueryClassifie import { resolveAddressIntent } from "../src/services/addressIntentResolver"; import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier"; import { extractAddressFilters } from "../src/services/addressFilterExtractor"; -import { AddressQueryService } from "../src/services/addressQueryService"; +import { AddressQueryService, ensureInventorySupplierOverlapLeadLine } 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"; @@ -157,6 +157,15 @@ describe("address query shape classifier", () => { expect(filters.warehouse).toBe("Основной склад"); }); + it("does not extract generic goods wording as counterparty for supplier-overlap identity questions", () => { + const filters = extractAddressFilters( + "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад", + "inventory_supplier_stock_overlap_as_of_date" + ).extracted_filters; + expect(filters.counterparty).toBeUndefined(); + expect(filters.warehouse).toBe("Основной склад"); + }); + it("cuts inventory item anchor before chain suffix and ignores chain pseudo-warehouse", () => { const filters = extractAddressFilters( "Через какие документы прошел путь товара Шкаф картотечный 1000*400*2100: закупка -> склад -> продажа", @@ -499,6 +508,35 @@ describe("address query shape classifier", () => { expect(reply.semantics?.result_mode).toBe("confirmed_balance"); }); + it("states directly when only purchase side of the inventory chain is confirmed", () => { + const reply = composeFactualReply( + "inventory_purchase_to_sale_chain", + [ + { + period: "2019-12-10T00:00:00Z", + registrator: "Поступление товаров и услуг 00000000111 от 10.12.2019 12:00:01", + account_dt: "41.01", + account_kt: "", + amount: 855000, + analytics: ["Шкаф картотечный 1000*400*2100", "Основной склад", "ООО Альтернатива Плюс", "ЭталонМебель"], + item: "Шкаф картотечный 1000*400*2100", + counterparty: "ЭталонМебель", + organization: "ООО Альтернатива Плюс" + } + ], + { + asOfDate: "2020-03-31", + userMessage: "Есть ли цепочка закупка -> продажа по товару Шкаф картотечный 1000*400*2100?" + } + ); + + expect(reply.text.split("\n")[0]).toContain("закупочная часть цепочки подтверждена"); + expect(reply.text.split("\n")[0]).toContain("продажа/выбытие со счета 41.01"); + expect(reply.text.split("\n")[0]).not.toContain("частично или разнообразно"); + expect(reply.text).toContain("Строк закупки на 41.01: 1"); + expect(reply.text).toContain("Строк продажи со счета 41.01: 0"); + }); + it("states when requested supplier-to-buyer chain parties are not confirmed by item documents", () => { const reply = composeFactualReply( "inventory_purchase_to_sale_chain", @@ -3648,6 +3686,24 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 9000 expect(result?.debug.reasons).toContain("as_of_date_from_analysis_context"); }); + it("detaches analysis-context snapshot date for inventory purchase-to-sale chain execution", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle( + "Через какие документы прошел путь товара Шкаф картотечный 1000*400*2100: закупка -> склад -> продажа", + { + analysisDateHint: "2020-03-31", + activeOrganization: 'ООО "Альтернатива Плюс"' + } + ); + + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("inventory_purchase_to_sale_chain"); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31"); + expect(result?.debug.reasons).toContain("as_of_date_from_analysis_context"); + expect(result?.debug.reasons).toContain("as_of_date_cleared_for_history_recovery"); + expect(result?.reply_text).toContain("Строк продажи со счета 41.01: 1"); + }); + it("returns soft out-of-scope reply without technical jargon for unsupported supplier-control wording", async () => { const service = new AddressQueryService(); const result = await service.tryHandle( @@ -4511,6 +4567,33 @@ it("auto-broadens out-of-window period after contracts pivot and keeps requested }); describe("address decompose stage follow-up carryover", () => { + it("keeps old-purchase residue follow-up in aging route with inherited stock date", () => { + const result = runAddressDecomposeStage("Какие остатки по товарам на эту дату относятся к старым закупкам", { + previous_intent: "inventory_on_hand_as_of_date", + target_intent: "inventory_on_hand_as_of_date", + root_intent: "inventory_on_hand_as_of_date", + current_frame_kind: "inventory_root", + previous_filters: { + as_of_date: "2021-09-30", + period_from: "2021-09-01", + period_to: "2021-09-30", + warehouse: "Основной склад", + organization: 'ООО "Альтернатива Плюс"' + }, + root_filters: { + as_of_date: "2021-09-30", + warehouse: "Основной склад", + organization: 'ООО "Альтернатива Плюс"' + } + }); + + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("inventory_aging_by_purchase_date"); + expect(result?.filters.extracted_filters.as_of_date).toBe("2021-09-30"); + expect(result?.filters.extracted_filters.warehouse).toBe("Основной склад"); + expect(result?.filters.extracted_filters.organization).toBe('ООО "Альтернатива Плюс"'); + }); + it("promotes selected-object supplier slang follow-up into inventory provenance with inherited date context", () => { const result = runAddressDecomposeStage('По выбранному объекту "Столешница 600*3050*26 дуб ниагара": кто это поставил нам', { previous_intent: "inventory_on_hand_as_of_date", @@ -4736,6 +4819,39 @@ describe("address decompose stage follow-up carryover", () => { expect(result.warnings).toContain("item_llm_semantics_ignored"); }); + it("clears generic goods nouns from llm semantic counterparty hints", () => { + const result = applyAddressLlmSemanticHintsToExtraction( + { + extracted_filters: { + sort: "period_desc", + warehouse: "Основной склад" + }, + missing_required_filters: [], + warnings: [], + semantic_frame: { + scope_kind: "explicit_anchor", + anchor_kind: "warehouse", + anchor_value: "Основной склад", + date_scope_kind: "implicit_current", + date_basis_hint: "implicit_current_snapshot", + self_scope_detected: false, + selected_object_scope_detected: false + } + }, + { + scope_target_kind: "counterparty", + scope_target_text: "товаров,", + date_scope_kind: "implicit_current", + self_scope_detected: false, + selected_object_scope_detected: false + } + ); + + expect(result.extracted_filters.counterparty).toBeUndefined(); + expect(result.extracted_filters.warehouse).toBe("Основной склад"); + expect(result.warnings).toContain("counterparty_cleared_low_quality_llm_semantics"); + }); + it("keeps slang all-customers-all-time wording in address lane via resolved intent fallback", () => { const result = runAddressDecomposeStage("выведи всех заков за все время", null); expect(result).not.toBeNull(); @@ -5889,7 +6005,7 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => { expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.Остатки("); expect(plan.query).toContain("Документ.ПоступлениеТоваровУслуг.Товары"); expect(plan.query).toContain("Остатки.КоличествоРазвернутыйОстатокДт > 0"); - expect(plan.query).toContain("Товары.Номенклатура В"); + expect(plan.query).not.toContain("Товары.Номенклатура В"); expect(plan.query).toContain("Товары.Ссылка.Дата <= ДАТАВРЕМЯ(2026, 4, 18, 23, 59, 59)"); }); @@ -6045,6 +6161,8 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => { ); expect(reply.responseType).toBe("FACTUAL_SUMMARY"); + expect(reply.text.split("\n")[0]).toContain("выявлен поставщик закупочного следа"); + expect(reply.text.split("\n")[0]).toContain("Торговый дом"); expect(reply.text).toContain("Что проверили:"); expect(reply.text).toContain("Опорные документы:"); expect(reply.text).not.toContain("exact-контур"); @@ -6070,17 +6188,133 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => { ], { asOfDate: "2021-09-30", - userMessage: "Какие товары сейчас висят в остатке без понятной привязки к поставщику", + userMessage: "Покажи закупочный след текущего складского остатка", + rawUserMessage: "Какие товары сейчас висят в остатке без понятной привязки к поставщику", useRubCurrency: true } ); expect(reply.responseType).toBe("FACTUAL_SUMMARY"); - expect(reply.text.split("\n")[0]).toContain("без явно выделенного поставщика"); - expect(reply.text).toContain("Позиции без явно выделенного поставщика:"); + expect(reply.text.split("\n")[0]).toContain("без надежной привязки к поставщику"); + expect(reply.text.split("\n")[0]).toContain("первые примеры ниже"); + expect(reply.text).toContain("Позиции без надежной привязки к поставщику:"); + expect(reply.text).toContain("Товар без поставщика"); + expect(reply.text).toContain("Опорные документы:"); expect(reply.text).not.toContain("Уточните"); }); + it("derives unresolved supplier-link month filters from raw user wording before organization clarification", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle("По какому поставщику проходит текущий товарный остаток", { + analysisDateHint: "2021-09-30", + knownOrganizations: ['ООО "Альтернатива Плюс"', 'ООО "Актив"'], + rawUserMessage: "Какие товары сейчас висят в остатке без понятной привязки к поставщику" + }); + + expect(result?.debug.detected_intent).toBe("inventory_supplier_stock_overlap_as_of_date"); + expect(result?.debug.extracted_filters).toMatchObject({ + as_of_date: "2021-09-30", + period_from: "2021-09-01", + period_to: "2021-09-30" + }); + }); + + it("keeps broad supplier-overlap answers direct-first when supplier is not separated", () => { + const reply = composeFactualReply( + "inventory_supplier_stock_overlap_as_of_date", + [ + { + period: "2021-09-30T00:00:00Z", + registrator: "Поступление товаров и услуг 00000000999 от 30.09.2021 0:00:00", + account_dt: "41.01", + account_kt: "60.01", + amount: 1200, + analytics: ["Товар без поставщика", "Основной склад"], + item: "Товар без поставщика", + warehouse: "Основной склад", + organization: 'ООО "Альтернатива Плюс"' + } + ], + { + asOfDate: "2021-09-30", + useRubCurrency: true + } + ); + + const firstLine = reply.text.split("\n")[0] ?? ""; + expect(firstLine).toContain("По складскому остатку Основной склад"); + expect(firstLine).toContain("однозначный поставщик текущего остатка не подтвержден"); + expect(firstLine).toContain("1"); + expect(firstLine).not.toContain("Часть закупочных операций"); + expect(reply.text).not.toContain("Часть закупочных операций"); + expect(reply.text).toContain("закупочных операций без явно выделенного поставщика"); + }); + + it("repairs supplier-overlap lead line when evidence rows display counterparties", () => { + const staleText = [ + "По складскому остатку Основной склад на 30.09.2021 однозначная атрибуция части текущего остатка не подтверждена: 192 закупочных операций без явно выделенного поставщика.", + "Что проверили:", + "- Дата среза: 30.09.2021.", + "Опорные документы:", + "1. Поступление товаров и услуг 00000000001 | контрагент: А", + "2. Поступление товаров и услуг 00000000030 | контрагент: ВИЗАНТИЯ", + "3. Поступление товаров и услуг 00000000031 | контрагент: ИП Тучкова" + ].join("\n"); + + const repaired = ensureInventorySupplierOverlapLeadLine("inventory_supplier_stock_overlap_as_of_date", staleText); + const lines = repaired.split("\n"); + + expect(lines[0]).toContain("найдено несколько поставщиков закупочного следа"); + expect(lines[0]).toContain("А"); + expect(lines[0]).toContain("ВИЗАНТИЯ"); + expect(lines[1]).toContain("однозначная атрибуция части текущего остатка не подтверждена"); + }); + + it("answers supplier-scoped stock questions with item list first", () => { + const reply = composeFactualReply( + "inventory_supplier_stock_overlap_as_of_date", + [ + { + period: "2019-02-12T00:00:00Z", + registrator: "Поступление товаров и услуг 00000000003 от 12.02.2019 0:00:00", + account_dt: "41.01", + account_kt: "60.01", + amount: 3075, + analytics: ["Столешница 600*3050*26 альмандин", "Основной склад", "Торговый дом \"Союз МСК\""], + item: "Столешница 600*3050*26 альмандин", + warehouse: "Основной склад", + counterparty: "Торговый дом \"Союз МСК\"", + organization: 'ООО "Альтернатива Плюс"' + }, + { + period: "2019-02-12T00:00:00Z", + registrator: "Поступление товаров и услуг 00000000003 от 12.02.2019 0:00:00", + account_dt: "41.01", + account_kt: "60.01", + amount: 3724.17, + analytics: ["Столешница 600*3050*26 дуб ниагара", "Основной склад", "Торговый дом \"Союз МСК\""], + item: "Столешница 600*3050*26 дуб ниагара", + warehouse: "Основной склад", + counterparty: "Торговый дом \"Союз МСК\"", + organization: 'ООО "Альтернатива Плюс"' + } + ], + { + asOfDate: "2021-09-30", + counterpartyHint: "Торговый дом \\Союз", + userMessage: "Какие товары от поставщика Торговый дом \\Союз сейчас еще лежат на складе Основной склад", + useRubCurrency: true + } + ); + + const firstLine = reply.text.split("\n")[0] ?? ""; + expect(firstLine).toContain("подтверждены позиции"); + expect(firstLine).toContain("Столешница 600*3050*26 альмандин"); + expect(firstLine).toContain("Столешница 600*3050*26 дуб ниагара"); + expect(firstLine).not.toContain("выявлен поставщик закупочного следа"); + expect(reply.text).toContain("Позиции:"); + }); + it("routes inventory provenance questions to a dedicated intent", () => { const result = resolveAddressIntent("От какого поставщика куплен товар Шкаф картоотечный?"); expect(result.intent).toBe("inventory_purchase_provenance_for_item"); @@ -6145,6 +6379,11 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => { expect(result.intent).toBe("inventory_aging_by_purchase_date"); }); + it("routes old-purchase residue follow-up wording to aging intent", () => { + const result = resolveAddressIntent("Какие остатки по товарам на эту дату относятся к старым закупкам"); + expect(result.intent).toBe("inventory_aging_by_purchase_date"); + }); + it("routes old stock wording with residue anchor to aging intent", () => { const result = resolveAddressIntent("Это остаток по очень давно купленному товару?"); expect(result.intent).toBe("inventory_aging_by_purchase_date"); diff --git a/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts b/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts index 9d7e0a3..4b814dc 100644 --- a/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts +++ b/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts @@ -526,6 +526,73 @@ describe("address reply builders regressions", () => { expect(result?.text).toContain("Следующий шаг: могу раскрыть полный список"); }); + it("surfaces account and warehouse scope in on-hand snapshot top line", () => { + const result = composeInventoryReply( + "inventory_on_hand_as_of_date", + [ + { + amount: 614098.33, + quantity: 13, + item: "Моноблок леново", + warehouse: "Основной склад", + organization: 'ООО "Альтернатива Плюс"', + period: "2019-03-31", + registrator: "Остатки" + } as any + ], + { + userMessage: "Какие товары числятся на 41 счете на дату 2019-03-31", + accountHint: "41", + warehouseHint: "Основной склад", + asOfDate: "2019-03-31" + }, + { + resolvePayablesAsOfDate: () => "2019-03-31", + buildInventoryOnHandAggregate: () => [ + { + item: "Моноблок леново", + warehouse: "Основной склад", + organization: 'ООО "Альтернатива Плюс"', + quantity: 13, + amount: 614098.33, + operations: 1, + firstPeriod: "2019-03-31", + lastPeriod: "2019-03-31", + sourceRefs: [] + } + ], + uniqueStrings: (values: string[]) => Array.from(new Set(values)), + formatDateRu: (value: string) => value, + formatNumberWithDots: (value: number, fractionDigits = 0) => value.toFixed(fractionDigits), + formatMoneyRub: (value: number) => `${value} ₽`, + isInventoryPurchaseMovement: () => false, + summarizeInventoryTraceRows: () => ({ + item: null, + warehouses: [], + organizations: [], + counterparties: [], + documents: [], + firstPeriod: null, + lastPeriod: null, + totalAmount: 0 + }), + formatInventoryTraceRows: () => [], + hasInventoryPurchaseDateActionFocus: () => false, + inventoryTraceDateLabel: () => "", + extractInventoryCounterpartyCandidates: () => [], + buildInventoryAgingByItemAggregate: () => [], + formatInventoryAgingRows: () => [], + isInventorySaleMovement: () => false + } + ); + + const firstLine = result?.text.split("\n")[0] ?? ""; + expect(firstLine).toContain("по счету 41"); + expect(firstLine).toContain("по складу Основной склад"); + expect(result?.text).toContain("Срез по счету 41."); + expect(result?.text).toContain("Срез по складу Основной склад."); + }); + it("answers cost-base evidence follow-up directly when margin ranking has sales without confirmed cost", () => { const result = composeInventoryReply( "inventory_margin_ranking_for_nomenclature", @@ -753,4 +820,87 @@ describe("address reply builders regressions", () => { expect(result?.text).not.toContain("сумма:"); expect(result?.text).not.toContain("8.520,00 ₽"); }); + + it("keeps supplier-overlap stock answer direct-first and compact on broad warehouse traces", () => { + const rows = [ + { + period: "2021-03-09", + registrator: "Поступление товаров и услуг 00000000001", + item: "Рабочая станция", + warehouse: "Основной склад", + counterparty: "А" + }, + { + period: "2020-11-20", + registrator: "Поступление товаров и услуг 00000000030", + item: "Зеркало", + warehouse: "Основной склад", + counterparty: "ВИЗАНТИЯ" + }, + { + period: "2020-07-13", + registrator: "Поступление товаров и услуг 00000000029", + item: "Стул", + warehouse: "Основной склад", + counterparty: "ИП Тучкова" + }, + { + period: "2019-01-10", + registrator: "Поступление товаров и услуг 00000000077", + item: "Позиция без поставщика", + warehouse: "Основной склад" + } + ] as any[]; + const result = composeInventoryReply( + "inventory_supplier_stock_overlap_as_of_date", + rows, + { + userMessage: "По какому поставщику проходит текущий товарный остаток на складе Основной склад", + asOfDate: "2021-09-30" + }, + { + resolvePayablesAsOfDate: () => "2021-09-30", + buildInventoryOnHandAggregate: () => [], + uniqueStrings: (values: string[]) => Array.from(new Set(values)), + formatDateRu: (value: string) => value, + formatNumberWithDots: (value: number, fractionDigits = 0) => value.toFixed(fractionDigits), + formatMoneyRub: (value: number) => `${value} ₽`, + isInventoryPurchaseMovement: () => true, + summarizeInventoryTraceRows: (inputRows: any[]) => ({ + item: inputRows[0]?.item ?? null, + warehouses: ["Основной склад"], + organizations: [], + counterparties: [], + documents: inputRows.map((row) => String(row.registrator ?? "")), + firstPeriod: "2019-01-10", + lastPeriod: "2021-03-09", + totalAmount: 0 + }), + formatInventoryTraceRows: (inputRows: any[], limit = 10) => + inputRows.slice(0, limit).map((row, index) => { + const parts = [`${index + 1}. ${row.registrator}`]; + if (row.counterparty) { + parts.push(`контрагент: ${row.counterparty}`); + } + return parts.join(" | "); + }), + hasInventoryPurchaseDateActionFocus: () => false, + inventoryTraceDateLabel: (value: string | null) => String(value ?? ""), + extractInventoryCounterpartyCandidates: () => [], + buildInventoryAgingByItemAggregate: () => [], + formatInventoryAgingRows: () => [], + isInventorySaleMovement: () => false + } + ); + + const text = result?.text ?? ""; + const firstLine = text.split("\n")[0] ?? ""; + expect(firstLine).toContain("найдено несколько поставщиков закупочного следа"); + expect(firstLine).toContain("А"); + expect(firstLine).toContain("ВИЗАНТИЯ"); + expect(text.indexOf(firstLine)).toBeLessThan(text.indexOf("Что проверили:")); + expect(text).toContain("однозначная атрибуция части текущего остатка не подтверждена: 1 закупочных операций без явно выделенного поставщика."); + expect(text).not.toContain("Часть закупочных операций"); + expect(text.match(/^\d+\./gmu)?.length ?? 0).toBeLessThanOrEqual(3); + }); }); diff --git a/llm_normalizer/backend/tests/assistantAddressLaneResponseRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantAddressLaneResponseRuntimeAdapter.test.ts index 898c015..ef1bdce 100644 --- a/llm_normalizer/backend/tests/assistantAddressLaneResponseRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressLaneResponseRuntimeAdapter.test.ts @@ -100,6 +100,126 @@ describe("assistant address lane response runtime adapter", () => { ); }); + it("repairs inventory supplier-overlap lead line before finalizing the user reply", () => { + const finalizeAddressTurn = vi.fn(() => ({ + response: { + ok: true + } + })); + + const runtime = runAssistantAddressLaneResponseRuntime({ + sessionId: "asst-supplier-overlap", + userMessage: "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад", + effectiveAddressUserMessage: "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад", + addressLane: { + handled: true, + reply_text: [ + "По складскому остатку Основной склад на 30.09.2021 однозначная атрибуция части текущего остатка не подтверждена: 192 закупочных операций без явно выделенного поставщика.", + "Что проверили:", + "- Дата среза: 30.09.2021.", + "Опорные документы:", + "1. Поступление товаров и услуг 00000000001 | контрагент: А", + "2. Поступление товаров и услуг 00000000030 | контрагент: ВИЗАНТИЯ", + "3. Поступление товаров и услуг 00000000031 | контрагент: ИП Тучкова" + ].join("\n"), + reply_type: "factual", + debug: { + extracted_filters: { + warehouse: "Основной склад", + as_of_date: "2021-09-30" + } + } + }, + knownOrganizations: [], + activeOrganization: null, + sanitizeOutgoingAssistantText: (text) => + String(text ?? "") + .split(/\r?\n/u) + .filter((line) => !line.includes("найдено несколько поставщиков закупочного следа")) + .join("\n") + .trim(), + buildAddressDebugPayload: (addressDebug) => ({ + ...(addressDebug as Record), + detected_intent: "inventory_supplier_stock_overlap_as_of_date" + }), + buildAddressFollowupOffer: () => null, + mergeKnownOrganizations: (items) => Array.from(new Set(items)), + toNonEmptyString: (value) => (typeof value === "string" && value.trim() ? value.trim() : null), + appendItem: () => {}, + getSession: () => ({ session_id: "asst-supplier-overlap", updated_at: "", items: [], investigation_state: null } as any), + persistSession: () => {}, + cloneConversation: (items) => items, + logEvent: () => {}, + messageIdFactory: () => "msg-supplier-overlap", + finalizeAddressTurn + }); + + const assistantReply = (finalizeAddressTurn.mock.calls[0]?.[0] as any)?.assistantReply ?? ""; + const nonEmptyLines = assistantReply.split("\n").filter((line: string) => line.trim().length > 0); + expect(nonEmptyLines[0]).toContain("найдено несколько поставщиков закупочного следа"); + expect(nonEmptyLines[0]).toContain("А"); + expect(nonEmptyLines[1]).toContain("однозначная атрибуция части текущего остатка не подтверждена"); + expect(runtime.debug.inventory_supplier_overlap_surface_repair_v1).toEqual( + expect.objectContaining({ + applied: true, + lead_preserved_after_sanitize: true, + counterparties: ["А", "ВИЗАНТИЯ", "ИП Тучкова"] + }) + ); + }); + + it("persists inventory root frame filters with a single warehouse derived from the factual reply", () => { + const runtime = runAssistantAddressLaneResponseRuntime({ + sessionId: "asst-inventory-root", + userMessage: "какие остатки на складе на сентябрь 2021", + effectiveAddressUserMessage: "какие остатки на складе на сентябрь 2021", + addressLane: { + handled: true, + reply_text: + 'На 30.09.2021 по складу Основной склад подтверждено 11 позиций.\n1. Диван | склад: Основной склад | организация: ООО "Альтернатива Плюс"', + reply_type: "factual", + debug: { + detected_intent: "inventory_on_hand_as_of_date", + extracted_filters: { + organization: 'ООО "Альтернатива Плюс"', + as_of_date: "2021-09-30", + period_from: "2021-09-01", + period_to: "2021-09-30" + } + } + }, + knownOrganizations: ['ООО "Альтернатива Плюс"'], + activeOrganization: 'ООО "Альтернатива Плюс"', + sanitizeOutgoingAssistantText: (text) => String(text ?? "").trim(), + buildAddressDebugPayload: (addressDebug) => ({ ...(addressDebug as Record) }), + buildAddressFollowupOffer: () => null, + mergeKnownOrganizations: (items) => Array.from(new Set(items)), + toNonEmptyString: (value) => (typeof value === "string" && value.trim() ? value.trim() : null), + appendItem: () => {}, + getSession: () => ({ session_id: "asst-inventory-root", updated_at: "", items: [], investigation_state: null } as any), + persistSession: () => {}, + cloneConversation: (items) => items, + logEvent: () => {}, + messageIdFactory: () => "msg-inventory-root", + finalizeAddressTurn: () => ({ response: { ok: true } as any }) + }); + + expect(runtime.debug.address_root_frame_context).toEqual( + expect.objectContaining({ + root_intent: "inventory_on_hand_as_of_date", + current_frame_kind: "inventory_root", + warehouse: "Основной склад", + root_filters: expect.objectContaining({ + organization: 'ООО "Альтернатива Плюс"', + warehouse: "Основной склад", + as_of_date: "2021-09-30", + period_from: "2021-09-01", + period_to: "2021-09-30" + }) + }) + ); + }); + it("attaches MCP discovery summary from predecompose runtime meta without changing the reply", () => { const runtime = runAssistantAddressLaneResponseRuntime({ sessionId: "asst-mcp", diff --git a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts index eeef00f..9e0142b 100644 --- a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts @@ -5,6 +5,7 @@ import { applyReferencedEntityCarryover, applySelectedItemCarryover, applyTemporalCarryoverFilters, + buildInventoryRootFrameFromAddressDebug, buildRootScopedCarryoverFilters, hydrateInventoryRootFrameState, readAddressDebugCounterparty, @@ -479,6 +480,35 @@ describe("assistantContinuityPolicy organization authority", () => { }); }); + it("builds inventory root frame from compact root-frame context without leaking drilldown item scope", () => { + const frame = buildInventoryRootFrameFromAddressDebug({ + detected_intent: "inventory_purchase_provenance_for_item", + extracted_filters: { + item: "Четки Пост (84*117)", + organization: 'ООО "Альтернатива Плюс"', + as_of_date: "2021-09-30" + }, + address_root_frame_context: { + root_intent: "inventory_on_hand_as_of_date", + organization: 'ООО "Альтернатива Плюс"', + warehouse: "Основной склад", + as_of_date: "2021-09-30", + period_from: "2021-09-01", + period_to: "2021-09-30", + current_frame_kind: "inventory_drilldown" + } + }); + + expect(frame?.filters).toEqual({ + organization: 'ООО "Альтернатива Плюс"', + warehouse: "Основной склад", + as_of_date: "2021-09-30", + period_from: "2021-09-01", + period_to: "2021-09-30" + }); + expect(frame?.filters).not.toHaveProperty("item"); + }); + it("does not carry MCP discovery counterparty from a stale replay-forbidden turn", () => { const filters = resolveAddressDebugCarryoverFilters({ detected_intent: "payables_confirmed_as_of_date", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index c221f1e..094cf3a 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -439,6 +439,108 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override"); }); + it("keeps exact inventory purchase-to-sale replies over stale value-flow clarification candidates", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: + "По товару Шкаф картотечный 1000*400*2100 закупочная часть цепочки подтверждена, но продажа/выбытие со счета 41.01 в доступных данных не найдены.", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "inventory_purchase_to_sale_chain", + selected_recipe: "address_inventory_purchase_to_sale_chain_v1", + mcp_call_status: "matched_non_empty", + truth_mode: "confirmed", + capability_binding_status: "bound", + capability_binding_violations: [], + answer_shape_contract: { + reply_type: "factual", + capability_contract_id: "inventory_inventory_purchase_to_sale_chain" + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + bridge: { + bridge_status: "needs_clarification", + user_facing_response_allowed: true, + business_fact_answer_allowed: false, + requires_user_clarification: true, + answer_draft: { + answer_mode: "clarification_required", + headline: + "Коротко: Могу посчитать общий денежный поток в проверяемом окне, но для проверяемого поиска в 1С нужен проверяемый период.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: "Уточните период, и я продолжу поиск по денежному потоку в 1С." + } + }, + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "counterparty_value", + asked_action_family: "payout", + unsupported_but_understood_family: "counterparty_payouts_or_outflow" + }, + data_need_graph: { + business_fact_family: "value_flow", + action_family: "payout", + clarification_gaps: ["period"] + } + } + }) + } + }); + + expect(result.applied).toBe(false); + expect(result.decision).toBe("keep_current_reply"); + expect(result.reply_text).toContain("закупочная часть цепочки подтверждена"); + expect(result.reply_text).not.toContain("денежный поток"); + expect(result.reason_codes).toContain( + "mcp_discovery_response_policy_keep_exact_inventory_chain_reply_over_value_flow_clarification" + ); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_candidate_applied"); + }); + + it("keeps confirmed supplier-overlap address replies over entity-resolution discovery candidates", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: + 'По складскому остатку Основной склад на 30.09.2021 выявлен поставщик закупочного следа: Торговый дом "Союз МСК".', + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "inventory_supplier_stock_overlap_as_of_date", + selected_recipe: "address_inventory_supplier_stock_overlap_as_of_date_v1", + mcp_call_status: "matched_non_empty", + truth_mode: "confirmed", + capability_binding_status: "bound", + capability_binding_violations: [], + answer_shape_contract: { + reply_type: "factual", + capability_contract_id: "inventory_inventory_supplier_stock_overlap_as_of_date" + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "entity_resolution", + asked_action_family: "search_business_entity", + unsupported_but_understood_family: "entity_resolution", + explicit_entity_candidates: ['Торговый дом "Союз"'] + } + } + }) + } + }); + + expect(result.applied).toBe(false); + expect(result.decision).toBe("keep_current_reply"); + expect(result.reply_text).toContain("выявлен поставщик закупочного следа"); + expect(result.reply_text).not.toContain("Confirmed fact"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_keep_exact_matched_factual_address_reply"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override"); + }); + it("lets metadata discovery override a stale exact address answer outside protected inventory chains", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "ИП Калинин Рќ.Рњ. | СЃСѓРјРјР°: 216600", diff --git a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts index 868a47f..0ed6022 100644 --- a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts @@ -336,6 +336,38 @@ describe("assistantTurnMeaningPolicy", () => { expect(meaning.reason_codes).not.toContain("broad_business_evaluation_current_turn_signal"); }); + it.each([ + { + rawUserMessage: "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад", + effectiveAddressUserMessage: "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад" + }, + { + rawUserMessage: "По какому поставщику проходит текущий товарный остаток на складе Основной склад", + effectiveAddressUserMessage: + "Определить поставщика, с которого получен текущий товарный остаток на складе Основной склад" + }, + { + rawUserMessage: "Какие товары от поставщика Торговый дом \\Союз сейчас еще лежат на складе Основной склад", + effectiveAddressUserMessage: + "Найти товары, которые находятся на складе Основной склад и были поставлены поставщиком Торговый дом \\Союз" + } + ])("keeps supplier stock-overlap wording in the exact inventory lane", ({ rawUserMessage, effectiveAddressUserMessage }) => { + const policy = buildPolicy(); + + const meaning = policy.resolveAssistantTurnMeaning({ + rawUserMessage, + effectiveAddressUserMessage + }); + + expect(meaning.explicit_intent_candidate).toBe("inventory_supplier_stock_overlap_as_of_date"); + expect(meaning.asked_domain_family).toBe("inventory"); + expect(meaning.asked_action_family).toBe("supplier_overlap"); + expect(meaning.unsupported_but_understood_family).toBeNull(); + expect(meaning.stale_replay_forbidden).toBe(false); + expect(meaning.reason_codes).toContain("address_intent_resolver_current_turn_signal"); + expect(meaning.reason_codes).not.toContain("broad_business_evaluation_current_turn_signal"); + }); + it("treats paired receivables/payables position as business overview instead of one debt side", () => { const policy = buildPolicy();