diff --git a/docs/orchestration/active_domain_contract.json b/docs/orchestration/active_domain_contract.json index cc9a035..8f4d3f6 100644 --- a/docs/orchestration/active_domain_contract.json +++ b/docs/orchestration/active_domain_contract.json @@ -771,9 +771,7 @@ }, "expected_result_mode": "confirmed_balance", "required_filters": { - "as_of_date": "2021-09-30", - "period_from": "2021-09-01", - "period_to": "2021-09-30" + "as_of_date": "2021-09-30" }, "invariant_severity": { "wrong_as_of_date": "P0", @@ -794,15 +792,11 @@ "source": "binding_target_date_historical" }, "expected_capability": "confirmed_inventory_on_hand_as_of_date", - "analysis_context": { - "as_of_date": "2021-09-30", - "source": "binding_target_date_current" - }, "expected_result_mode": "confirmed_balance", "required_filters": { - "as_of_date": "2021-09-30", - "period_from": "2021-09-01", - "period_to": "2021-09-30" + "as_of_date": "2019-03-31", + "period_from": "2019-03-01", + "period_to": "2019-03-31" }, "invariant_severity": { "wrong_as_of_date": "P0", @@ -844,8 +838,7 @@ }, "required_filters": { "as_of_date": "2021-09-30", - "period_from": "2021-09-01", - "period_to": "2021-09-30" + "account": "41" }, "invariant_severity": { "wrong_as_of_date": "P0", @@ -1074,9 +1067,7 @@ "organization_scope" ], "required_filters": { - "as_of_date": "2019-03-31", - "period_from": "2019-03-01", - "period_to": "2019-03-31" + "as_of_date": "2019-03-31" }, "invariant_severity": { "wrong_as_of_date": "P0", @@ -1222,9 +1213,24 @@ "step_01_snapshot_historical", "step_02_selected_item_supplier_ui" ], + "analysis_context": { + "as_of_date": "2019-03-31", + "source": "binding_target_date_historical" + }, + "expected_capability": "inventory_purchase_provenance_for_item", "required_state_objects": [ "focus_object" ], + "required_filters": { + "as_of_date": "2019-03-31" + }, + "required_carryover_invariants": [ + "selected_object", + "focus_object", + "date_scope", + "reusable_bundle", + "followup_action_resolution" + ], "forbidden_capabilities": [ "confirmed_inventory_on_hand_as_of_date" ], diff --git a/llm_normalizer/backend/dist/services/addressCoverageEvidencePolicy.js b/llm_normalizer/backend/dist/services/addressCoverageEvidencePolicy.js index 5c850ad..2e8452c 100644 --- a/llm_normalizer/backend/dist/services/addressCoverageEvidencePolicy.js +++ b/llm_normalizer/backend/dist/services/addressCoverageEvidencePolicy.js @@ -109,6 +109,10 @@ function isConfirmedBalanceIntent(intent) { intent === "vat_liability_confirmed_for_tax_period"); } function resolveAddressAsOfDateBasis(filters, semanticFrame) { + if (semanticFrame?.date_scope_kind === "implicit_current" && + semanticFrame.date_basis_hint === "implicit_current_snapshot") { + return "implicit_current_snapshot"; + } const asOfDate = normalizeIsoDateHint(filters.as_of_date); if (asOfDate) { return "explicit_as_of_date"; diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 19fae6b..008696d 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -166,8 +166,11 @@ function toIsoDate(year, month, day) { } return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } +function hasImplicitCurrentAsOfDateCue(text) { + return /\b(сегодня|на\s+сегодня|на\s+текущ(?:ую|ая|ий|ее|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодняшн(?:юю|ий|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+текущ(?:ий|ую)\s+момент|today|as\s+of\s+today|current\s+date|as\s+of\s+current\s+date)\b/i.test(text); +} function extractAsOfDate(text) { - if (/\b(сегодня|на\s+сегодня|на\s+текущ(?:ую|ая|ий|ее|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодняшн(?:юю|ий|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+текущ(?:ий|ую)\s+момент|today|as\s+of\s+today|current\s+date|as\s+of\s+current\s+date)\b/i.test(text)) { + if (hasImplicitCurrentAsOfDateCue(text)) { return new Date().toISOString().slice(0, 10); } const ymd = text.match(DATE_YMD_PATTERN); @@ -657,6 +660,8 @@ function isLowQualityCounterpartyAnchorValue(rawValue) { "ноябрь", "декабрь" ]); + const isLowQualityTimeToken = (token) => lowQualityTimeTokens.has(token) || + /^(?:январ|феврал|март|апрел|ма(?:й|я|е)|июн|июл|август|сентябр|октябр|ноябр|декабр)/iu.test(token); const lowQualityGenericTokens = new Set([ "деньги", "денег", @@ -680,13 +685,13 @@ function isLowQualityCounterpartyAnchorValue(rawValue) { "целом" ]); const meaningfulNonTemporalTokens = tokens.filter((token) => isLikelyCounterpartyToken(token) && - !lowQualityTimeTokens.has(token) && + !isLowQualityTimeToken(token) && !/^(?:19|20)\d{2}$/.test(token)); if (meaningfulNonTemporalTokens.length === 0 && hasTemporalCue) { return true; } const meaningfulNonGenericTokens = tokens.filter((token) => isLikelyCounterpartyToken(token) && - !lowQualityTimeTokens.has(token) && + !isLowQualityTimeToken(token) && !lowQualityGenericTokens.has(token) && !/^(?:19|20)\d{2}$/.test(token)); if (meaningfulNonGenericTokens.length === 0 && (hasTemporalCue || paymentCue)) { @@ -1133,6 +1138,9 @@ function isTemporalWarehousePhrase(candidate) { .toLowerCase() .replace(/ё/g, "е") .trim(); + if (/^(?:в|на)?\s*(?:сейчас|сегодня|текущ(?:ий|ую|ем|его)\s+момент|данн(?:ый|ую|ом|ого)\s+момент)$/iu.test(normalized)) { + return true; + } if (/^(?:на\s+)?(?:эту|ту|текущ(?:ую|ая|ий|ее|ей)|сегодняшн(?:юю|ая|ий|ее|ей))(?:\s+же)?\s+дат(?:у|а|е|ой)$/iu.test(normalized)) { return true; } @@ -1177,6 +1185,12 @@ function isLowQualityWarehouseAnchorValue(rawValue) { "лежали", "на", "по", + "компания", + "компании", + "компанию", + "организация", + "организации", + "организацию", "складе", "складу", "складом", @@ -1195,7 +1209,10 @@ function isLowQualityWarehouseAnchorValue(rawValue) { if (tokens.length === 0) { return true; } - const meaningfulTokens = tokens.filter((token) => !lowQualityTokens.has(token) && token.length > 1); + const isLowQualityWarehouseToken = (token) => lowQualityTokens.has(token) || + /^(?:19|20)\d{2}$/.test(token) || + /^(?:январ|феврал|март|апрел|ма(?:й|я|е)|июн|июл|август|сентябр|октябр|ноябр|декабр)/iu.test(token); + const meaningfulTokens = tokens.filter((token) => !isLowQualityWarehouseToken(token) && token.length > 1); if (meaningfulTokens.length === 0) { return true; } @@ -1256,11 +1273,13 @@ function extractInventoryWarehouseAnchor(text) { return undefined; } function extractInventorySupplierAnchor(text) { - const match = String(text ?? "").match(/(?:от\s+поставщика|у\s+поставщика|поставщика|поставщику)\s+([^\r\n?]+?)(?=$|[?])/iu); + const match = String(text ?? "").match(/(?:от\s+поставщика|у\s+поставщика|поставщик(?:а|у|ом)?|supplier|vendor)\s+([^\r\n?]+?)(?=$|[?])/iu); if (!match?.[1]) { return undefined; } - const candidate = cleanupAnchorValue(cleanupAnchorValue(String(match[1])).replace(/\s+(?:сейчас|на\s+склад(?:е|у|ом)?|на\s+дату|по\s+состоянию\s+на\s+дату|котор(?:ый|ые|ых)|куплен(?:ы|а|о)?|были|был|лежат|лежит|еще|ещё|организац\w*|компани\w*).*$/iu, "")); + const candidate = cleanupAnchorValue(cleanupAnchorValue(String(match[1])) + .replace(/\s+(?:сейчас|на\s+склад(?:е|у|ом)?|на\s+дату|по\s+состоянию\s+на\s+дату|котор(?:ый|ые|ых)|куплен(?:ы|а|о)?|были|был|лежат|лежит|еще|ещё|организац\w*|компани\w*).*$/iu, "") + .replace(/\s*(?:->|=>|→)\s*(?:товар|позици|номенклатур|item|product|sku)[\s\S]*$/iu, "")); if (!candidate || isLowQualityCounterpartyAnchorValue(candidate) || /^(?:были|был|куплен|куплены|которые|который|которых|сейчас|лежат|лежит)\b/iu.test(candidate)) { @@ -1369,7 +1388,7 @@ function resolveSemanticDateScopeKind(filters, warnings) { return "none"; } function resolveSemanticDateBasisHint(filters, warnings) { - if (warnings.includes("as_of_date_defaulted_today")) { + if (warnings.includes("as_of_date_defaulted_today") || warnings.includes("as_of_date_from_implicit_current_phrase")) { return "implicit_current_snapshot"; } const hasAsOfDate = typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0; @@ -1470,6 +1489,7 @@ function extractAddressFilters(userMessage, intent) { } } const warnings = []; + const implicitCurrentAsOfDateCue = hasImplicitCurrentAsOfDateCue(text); const explicitAsOfDate = extractAsOfDate(text); const explicitAsOfDateWithCue = extractAsOfDateWithCue(text); const accountMatch = text.match(ACCOUNT_REVERSE_PATTERN) ?? text.match(ACCOUNT_PATTERN); @@ -1510,6 +1530,13 @@ function extractAddressFilters(userMessage, intent) { filters.counterparty = supplierAnchor; } } + if (intent === "inventory_purchase_to_sale_chain") { + const supplierAnchor = asksForInventorySupplierIdentity(text) ? undefined : extractInventorySupplierAnchor(text); + if (supplierAnchor) { + filters.counterparty = supplierAnchor; + warnings.push("supplier_anchor_derived_for_inventory_documentary_chain"); + } + } const allowGenericCounterpartyAnchor = !isInventoryTraceIntent(intent); const counterpartyMatch = allowGenericCounterpartyAnchor ? text.match(COUNTERPARTY_PATTERN) : null; if (counterpartyMatch && !filters.counterparty) { @@ -1655,6 +1682,9 @@ function extractAddressFilters(userMessage, intent) { } if (usesAsOfPrimaryWindow(intent) && explicitAsOfDate) { filters.as_of_date = explicitAsOfDate; + if (implicitCurrentAsOfDateCue && !warnings.includes("as_of_date_from_implicit_current_phrase")) { + warnings.push("as_of_date_from_implicit_current_phrase"); + } const periodWasDerivedHeuristically = warnings.includes("period_derived_from_month_phrase") || warnings.includes("period_derived_from_year_range_phrase") || warnings.includes("period_derived_from_year_phrase"); @@ -1670,6 +1700,9 @@ function extractAddressFilters(userMessage, intent) { const asOfDate = extractAsOfDate(text); if (asOfDate) { filters.as_of_date = asOfDate; + if (implicitCurrentAsOfDateCue && !warnings.includes("as_of_date_from_implicit_current_phrase")) { + warnings.push("as_of_date_from_implicit_current_phrase"); + } } } // For counterparty document/bank lists we keep period open by default (all-time over available data) diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index b00e3a4..7138b7e 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1416,6 +1416,12 @@ function hasInventoryPurchaseDocumentsSignalV2(text) { return hasItemCue && hasPurchaseDocCue; } function hasInventorySaleTraceSignalV2(text) { + const value = String(text ?? ""); + const hasPlainItemCue = /(?:\u0442\u043e\u0432\u0430\u0440|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440|\u043f\u043e\u0437\u0438\u0446|\u043f\u0440\u043e\u0434\u0443\u043a\u0446\u0438|sku|item|product)/iu.test(value); + const hasPlainTraceCue = /(?:\u043a\u043e\u043c\u0443\s+(?:\u0432\s+\u0438\u0442\u043e\u0433\u0435\s+)?(?:\u043c\u044b\s+)?(?:\u043f\u0440\u043e\u0434\u0430\u043b\u0438|\u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043b\u0438|\u0432\u043f\u0430\u0440\u0438\u043b\u0438)|\u043a\u043e\u043c\u0443\s+(?:\u0431\u044b\u043b[\u0430\u0438\u043e]?|\u0431\u044b\u043b\u0438)?\s*(?:\u043f\u0440\u043e\u0434\u0430\u043d|\u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d)|\u043a\u0442\u043e\s+\u043a\u0443\u043f\u0438\u043b|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|buyer|sale\s+trace|trace\s+of\s+sale)/iu.test(value); + if (hasPlainItemCue && (hasPlainTraceCue || (0, inventoryLifecycleCueHelpers_1.hasInventorySaleCue)(value))) { + return true; + } const hasItemCue = /(?:товар|номенклатур|sku|item|product|позици(?:СЏ|СЋ|Рё)|продукци(?:СЏ|СЋ|Рё))/iu.test(text); const hasTraceCue = /(?:РєРѕРјСѓ\s+(?:РІ\s+итоге\s+)?(?:РјС‹\s+)?продали|РєРѕРјСѓ\s+был\s+продан|РєСѓРґР°\s+(?:РІ\s+итоге\s+)?(?:РјС‹\s+)?продали(?:\s+(?:это|его|товар|позицию))?|РєСѓРґР°\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+РєСѓРїРёР»|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+РїСЂРѕС€[её]Р»\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test(text); return hasItemCue && hasTraceCue; @@ -1435,6 +1441,12 @@ function hasInventoryAgingSignal(text) { return hasAgingCue || (hasResidueCue && /(?:давно\s+куплен|давно\s+приобретен|задолго\s+РґРѕ)/iu.test(text)); } function hasInventoryPurchaseToSaleChainSignal(text) { + const value = String(text ?? ""); + const hasPlainItemCue = /(?:товар|номенклатур|позици|sku|item|product)/iu.test(value); + const hasPlainChainCue = /(?:закупк[а-яё]*\s*->\s*склад\s*->\s*продаж|закупк[а-яё]*[\s\S]{0,80}склад[\s\S]{0,80}продаж|через\s+какие\s+документы\s+прош[её]л\s+путь|путь\s+товар[а-яё]*[\s\S]{0,80}закуп|цепочк[а-яё]*\s+движен|документально\s+подтвержденн[а-яё]*\s+цепочк|supplier\s*->\s*item\s*->\s*(?:buyer|customer)|supplier\s+to\s+buyer|supplier\s+to\s+item\s+to\s+buyer|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale)/iu.test(value) || value.includes("->"); + if (hasPlainItemCue && hasPlainChainCue) { + return true; + } const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); const hasChainCue = /(?:закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|закупка\s*->\s*склад\s*->\s*продажа|цепочк[аи]\s+движен|документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*(?:buyer|customer)|supplier\s+to\s+buyer|supplier\s+to\s+item\s+to\s+buyer)/iu.test(text) || text.includes("->"); return hasItemCue && hasChainCue; @@ -1603,6 +1615,10 @@ function resolveUnicodeAddressIntentBridge(text) { ]).has(byAnchorToken); const hasMoneyCue = /(?:деньг|денег|выручк|доход|оборот|заработ|прин[её]с|чек|ликвидн|revenue|turnover|money)/iu.test(normalized); const hasRankingCue = /(?:топ|ранк|сам(?:ый|ая|ое|ые)|больше\s+всего|наибольш|крупн|жирн|max|top|rank)/iu.test(normalized); + const hasInventoryPurchaseToSaleDocumentChainCue = /(?:закупк[а-яё]*[\s\S]{0,80}склад[\s\S]{0,80}продаж|путь\s+товар[а-яё]*[\s\S]{0,80}закуп|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|->\s*(?:склад|warehouse|stock)\s*->\s*(?:продаж|sale))/iu.test(normalized) && /(?:товар|позици|номенклатур|sku|item|product)/iu.test(normalized); + if (hasInventoryPurchaseToSaleDocumentChainCue) { + return unicodeBridgeResolution("inventory_purchase_to_sale_chain", "high", "unicode_inventory_purchase_to_sale_chain_bridge_signal_detected"); + } const hasOpenItemsAccountCue = /(?:хвост|долг|незакрыт|вис)/iu.test(normalized) && /(?:сч(?:е|ё)т(?:а|у|ом|е|ов)?\s*(?:№|#)?\s*(?:60|62|76)(?:[.,]\d{1,2})?|\b(?:60|62|76)(?:[.,]\d{1,2})?\b\s*сч(?:е|ё)т)/iu.test(normalized); if (hasOpenItemsAccountCue) { diff --git a/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js b/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js index ea9578d..3e2f43b 100644 --- a/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js +++ b/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js @@ -89,9 +89,13 @@ function hasInventoryProvenanceSignalV2(text) { return hasItemCue && hasSupplierCue && hasPurchaseCue; } function hasInventoryPurchaseDateSignal(text) { - const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text) || hasSelectedObjectInventoryCue(text); - const hasPurchaseDateCue = /(?:РєРѕРіРґР°\s+(?:примерно\s+)?(?:РјС‹\s+)?купили|РєРѕРіРґР°\s+был\s+куплен|РєРѕРіРґР°\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(text) || - /(?:РєРѕРіРґР°\s+был(?:Р°|Рё|Рѕ)?\s+закупк\w*|РєРѕРіРґР°\s+закупк\w*)/iu.test(text); + const value = String(text ?? ""); + const hasItemCue = /(?:\u0442\u043e\u0432\u0430\u0440|\u043f\u043e\u0437\u0438\u0446|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440|sku|item|product)/iu.test(value) || + /(?:товар|номенклатур|sku|item|product)/iu.test(value) || + hasSelectedObjectInventoryCue(value); + const hasPurchaseDateCue = /(?:\u043a\u043e\u0433\u0434\u0430\s+(?:\u043f\u0440\u0438\u043c\u0435\u0440\u043d\u043e\s+)?(?:\u043c\u044b\s+)?\u043a\u0443\u043f\u0438\u043b\u0438|\u043a\u043e\u0433\u0434\u0430\s+\u0431\u044b\u043b(?:\u0430|\u0438|\u043e)?\s+\u043a\u0443\u043f\u043b\u0435\u043d|\u043a\u043e\u0433\u0434\u0430\s+\u043a\u0443\u043f\u043b\u0435\u043d|\u0434\u0430\u0442\u0430\s+\u0437\u0430\u043a\u0443\u043f\u043a|purchase\s+date)/iu.test(value) || + /(?:РєРѕРіРґР°\s+(?:примерно\s+)?(?:РјС‹\s+)?купили|РєРѕРіРґР°\s+был\s+куплен|РєРѕРіРґР°\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(value) || + /(?:РєРѕРіРґР°\s+был(?:Р°|Рё|Рѕ)?\s+закупк\w*|РєРѕРіРґР°\s+закупк\w*)/iu.test(value); return hasItemCue && hasPurchaseDateCue; } function hasInventoryPurchaseDocumentsSignalV2(text) { @@ -108,7 +112,7 @@ function hasInventoryPurchaseDocumentsSignalV2(text) { function hasInventorySaleTraceSignalV2(text) { const value = String(text ?? ""); const hasPlainItemCue = /(?:товар|номенклатур|позици|продукци|sku|item|product)/iu.test(value); - const hasPlainTraceCue = /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?(?:продали|реализовали|впарили)|кому\s+(?:был[аио]?|были)?\s*реализован|кто\s+купил|покупател|buyer|sale\s+trace|trace\s+of\s+sale)/iu.test(value); + const hasPlainTraceCue = /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?(?:продали|реализовали|впарили)|кому\s+(?:был[аио]?|были)?\s*(?:продан|реализован)|кто\s+купил|покупател|buyer|sale\s+trace|trace\s+of\s+sale)/iu.test(value); if (hasPlainItemCue && hasPlainTraceCue) { return true; } diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index c01a10e..5784850 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -211,6 +211,20 @@ function deriveTaxQuarterWindowForDate(value) { period_to: `${year}-${String(quarterEndMonth).padStart(2, "0")}-${String(quarterEndDay).padStart(2, "0")}` }; } +function deriveMonthWindowForDate(value) { + const isoDate = normalizeIsoDateForQuery(value); + if (!isoDate) { + return null; + } + const match = isoDate.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + return { + period_from: `${match[1]}-${match[2]}-01`, + period_to: isoDate + }; +} function toDateTimeExprForQuery(isoDate) { const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!match) { @@ -1173,6 +1187,8 @@ function toNormalizedRows(rows) { const item = resolveInventoryItemFromRawRow(row, accountDt, accountKt); const warehouse = firstNonEmptyString(row.Склад, row.Warehouse, row.warehouse, row.СкладПредставление); const organization = firstNonEmptyString(row.Организация, row.Organization, row.organization, row.organization_name, row.ОрганизацияПредставление); + const counterparty = firstNonEmptyString(row.Контрагент, row.Counterparty, row.counterparty); + const contract = firstNonEmptyString(row.Договор, row.Contract, row.contract); const analytics = collectAnalyticsStrings(row); return { period, @@ -1184,7 +1200,9 @@ function toNormalizedRows(rows) { quantity, item, warehouse, - organization + organization, + counterparty, + contract }; }) .filter((item) => Boolean(item.period || item.registrator)); @@ -1235,6 +1253,10 @@ function formatMoneyRubForReply(value) { }).format(value)} ₽`; } function extractContractNameFromNormalizedRow(row) { + const explicitContract = firstNonEmptyString(row.contract); + if (explicitContract) { + return explicitContract; + } for (const token of row.analytics) { const normalized = String(token ?? "").trim(); if (!normalized) { @@ -1539,6 +1561,10 @@ function applyPreExecutionOrganizationScopeGrounding(input) { ]); const resolvedOrganizationFromMessage = (0, assistantOrganizationMatcher_1.resolveOrganizationSelectionFromMessage)(input.userMessage, candidateOrganizations); const referentialOrganizationScopeDetected = hasReferentialOrganizationScopeSignal(input.userMessage); + const counterpartyAnchorProtectsOrganizationScope = input.semanticFrame?.anchor_kind === "counterparty" && + typeof input.filters.counterparty === "string" && + input.filters.counterparty.trim().length > 0 && + !referentialOrganizationScopeDetected; if (!input.filters.organization && input.semanticFrame?.scope_kind === "implicit_self_scope" && activeOrganization) { @@ -1552,6 +1578,7 @@ function applyPreExecutionOrganizationScopeGrounding(input) { } if (resolvedOrganizationFromMessage && (!input.filters.organization || input.semanticFrame?.anchor_kind === "organization") && + !counterpartyAnchorProtectsOrganizationScope && !sameNormalizedOrganizationScope(input.filters.organization ?? null, resolvedOrganizationFromMessage)) { input.filters.organization = resolvedOrganizationFromMessage; if (!input.warnings.includes("organization_grounded_from_scope_candidates")) { @@ -1921,6 +1948,9 @@ function hasExplicitPeriodWindow(filters) { return ((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) || (typeof filters.period_to === "string" && filters.period_to.trim().length > 0)); } +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 canAutoBroadenPeriodWindow(intent, filters) { const hasRecoverableAsOfOnlyWindow = !hasExplicitPeriodWindow(filters) && typeof filters.as_of_date === "string" && @@ -1956,11 +1986,14 @@ function shouldClearAsOfDateForHistoryRecovery(intent) { intent === "inventory_purchase_to_sale_chain"); } function shouldDetachLifecycleExecutionFromSnapshotContext(intent, reasons) { - if (intent !== "inventory_sale_trace_for_item" && + if (intent !== "inventory_supplier_stock_overlap_as_of_date" && + intent !== "inventory_sale_trace_for_item" && intent !== "inventory_purchase_to_sale_chain") { return false; } - return (reasons.includes("as_of_date_from_followup_context") || + return (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") || reasons.includes("as_of_date_from_open_items_followup_context")); } @@ -2820,6 +2853,84 @@ class AddressQueryService { }); const knownOrganizations = (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)(options.knownOrganizations ?? []); const activeOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.activeOrganization ?? null); + const previousOrganizationFromContext = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(followupContext?.previous_filters?.organization ?? null); + const chainCounterpartyAnchor = toNonEmptyFilterValue(filters.extracted_filters.counterparty); + const chainOrganizationAnchor = toNonEmptyFilterValue(filters.extracted_filters.organization); + if (intent.intent === "inventory_purchase_to_sale_chain" && + chainCounterpartyAnchor && + chainOrganizationAnchor && + sameOrganizationEntityReference(chainOrganizationAnchor, chainCounterpartyAnchor)) { + delete filters.extracted_filters.organization; + const restoredOrganization = activeOrganization ?? previousOrganizationFromContext; + if (restoredOrganization && !sameOrganizationEntityReference(restoredOrganization, chainCounterpartyAnchor)) { + filters.extracted_filters.organization = restoredOrganization; + if (!filters.warnings.includes("organization_restored_from_inventory_chain_context")) { + filters.warnings.push("organization_restored_from_inventory_chain_context"); + } + if (!baseReasons.includes("organization_restored_from_inventory_chain_context")) { + baseReasons.push("organization_restored_from_inventory_chain_context"); + } + } + else { + if (!filters.warnings.includes("organization_cleared_from_inventory_chain_counterparty_anchor")) { + filters.warnings.push("organization_cleared_from_inventory_chain_counterparty_anchor"); + } + if (!baseReasons.includes("organization_cleared_from_inventory_chain_counterparty_anchor")) { + baseReasons.push("organization_cleared_from_inventory_chain_counterparty_anchor"); + } + } + } + if (intent.intent === "inventory_supplier_stock_overlap_as_of_date" && + (followupContext?.root_filters || followupContext?.previous_filters) && + !toNonEmptyFilterValue(filters.extracted_filters.period_from) && + !toNonEmptyFilterValue(filters.extracted_filters.period_to) && + !/(?:за\s+вс[её]\s+время|за\s+любой\s+период|all[\s-]?time|all\s+periods?)/iu.test(userMessage)) { + const snapshotContextFilters = followupContext?.root_filters && + (toNonEmptyFilterValue(followupContext.root_filters.period_from) || + toNonEmptyFilterValue(followupContext.root_filters.period_to)) + ? followupContext.root_filters + : followupContext?.previous_filters; + const previousPeriodFrom = toNonEmptyFilterValue(snapshotContextFilters?.period_from); + const previousPeriodTo = toNonEmptyFilterValue(snapshotContextFilters?.period_to); + if (previousPeriodFrom || previousPeriodTo) { + filters.extracted_filters = { + ...filters.extracted_filters, + ...(previousPeriodFrom ? { period_from: previousPeriodFrom } : {}), + ...(previousPeriodTo ? { period_to: previousPeriodTo } : {}) + }; + if (!toNonEmptyFilterValue(filters.extracted_filters.as_of_date)) { + const inheritedAsOfDate = toNonEmptyFilterValue(snapshotContextFilters?.as_of_date) ?? previousPeriodTo ?? previousPeriodFrom; + if (inheritedAsOfDate) { + filters.extracted_filters.as_of_date = inheritedAsOfDate; + } + } + if (!filters.warnings.includes("period_window_semantic_from_inventory_snapshot_context")) { + filters.warnings.push("period_window_semantic_from_inventory_snapshot_context"); + } + if (!baseReasons.includes("period_window_semantic_from_inventory_snapshot_context")) { + baseReasons.push("period_window_semantic_from_inventory_snapshot_context"); + } + } + } + if (intent.intent === "inventory_supplier_stock_overlap_as_of_date" && + !toNonEmptyFilterValue(filters.extracted_filters.period_from) && + !toNonEmptyFilterValue(filters.extracted_filters.period_to) && + asksForUnresolvedInventorySupplierLink(userMessage) && + !/(?:Р·Р°\s+РІСЃ[её]\s+время|Р·Р°\s+любой\s+период|all[\s-]?time|all\s+periods?)/iu.test(userMessage)) { + const monthWindow = deriveMonthWindowForDate(filters.extracted_filters.as_of_date); + if (monthWindow) { + filters.extracted_filters = { + ...filters.extracted_filters, + ...monthWindow + }; + if (!filters.warnings.includes("period_window_semantic_from_inventory_as_of_month")) { + filters.warnings.push("period_window_semantic_from_inventory_as_of_month"); + } + if (!baseReasons.includes("period_window_semantic_from_inventory_as_of_month")) { + baseReasons.push("period_window_semantic_from_inventory_as_of_month"); + } + } + } if (isOrganizationScopedValueFlowIntent(intent.intent) && hasExplicitSingleOrganizationValueFlowScopeRequest(userMessage) && !resolvedOrganizationFromMessage) { @@ -2969,13 +3080,14 @@ class AddressQueryService { const detachedExecutionFilters = { ...executionFilters }; let periodDetached = false; let asOfDetached = false; + const keepAsOfDateForInventorySnapshotOverlap = intent.intent === "inventory_supplier_stock_overlap_as_of_date"; if (toNonEmptyFilterValue(detachedExecutionFilters.period_from) || toNonEmptyFilterValue(detachedExecutionFilters.period_to)) { delete detachedExecutionFilters.period_from; delete detachedExecutionFilters.period_to; periodDetached = true; } - if (toNonEmptyFilterValue(detachedExecutionFilters.as_of_date)) { + if (!keepAsOfDateForInventorySnapshotOverlap && toNonEmptyFilterValue(detachedExecutionFilters.as_of_date)) { delete detachedExecutionFilters.as_of_date; asOfDetached = true; } @@ -3452,6 +3564,18 @@ class AddressQueryService { : anchor.anchor_type === "contract" && anchor.anchor_value_resolved ? { ...executionFilters, contract: anchor.anchor_value_resolved } : executionFilters; + if (intent.intent === "inventory_purchase_to_sale_chain" && + toNonEmptyFilterValue(filtersForMatching.item) && + toNonEmptyFilterValue(filtersForMatching.counterparty)) { + filtersForMatching = { ...filtersForMatching }; + delete filtersForMatching.counterparty; + if (!filters.warnings.includes("inventory_chain_counterparty_anchor_kept_for_verification")) { + filters.warnings.push("inventory_chain_counterparty_anchor_kept_for_verification"); + } + if (!baseReasons.includes("inventory_chain_counterparty_anchor_kept_for_verification")) { + baseReasons.push("inventory_chain_counterparty_anchor_kept_for_verification"); + } + } const accountScopeAudit = buildAccountScopeAudit({ intent: intent.intent, filters: filtersForMatching, diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index 01322e8..fbe3274 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -1235,6 +1235,26 @@ function buildInventoryPurchaseDocumentQuery(filters, resolvedLimit) { .replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Товары.Ссылка.Дата", ['Товары.Ссылка.Проведен = ИСТИНА', itemCondition].filter((item) => Boolean(item)))) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); } +function stripTrailingOrderBy(query) { + return String(query ?? "").replace(/\r?\nУПОРЯДОЧИТЬ ПО[\s\S]*$/u, "").trimEnd(); +} +function removeTopLimit(query) { + return String(query ?? "").replace(/ВЫБРАТЬ ПЕРВЫЕ\s+\d+/u, "ВЫБРАТЬ"); +} +function buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit) { + const purchaseQuery = removeTopLimit(stripTrailingOrderBy(buildInventoryPurchaseDocumentQuery(filters, resolvedLimit))); + const saleQuery = removeTopLimit(stripTrailingOrderBy(buildInventorySaleDocumentQuery(filters, resolvedLimit))); + return [ + purchaseQuery, + "", + "ОБЪЕДИНИТЬ ВСЕ", + "", + saleQuery, + "", + "УПОРЯДОЧИТЬ ПО", + ` Период ${resolveOrderDirection(filters.sort)}` + ].join("\n"); +} function buildCounterpartyPurchaseDocumentQuery(filters, resolvedLimit) { const goodsCounterpartyCondition = buildCounterpartyReferenceCondition(filters, ["Товары.Ссылка.Контрагент"]); const servicesCounterpartyCondition = buildCounterpartyReferenceCondition(filters, ["Услуги.Ссылка.Контрагент"]); @@ -1463,9 +1483,9 @@ function buildAddressRecipePlan(recipe, filters) { : recipe.query_template === "inventory_supplier_stock_overlap_profile" ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") : recipe.query_template === "inventory_sale_trace_profile" - ? buildInventoryMovementQuery(filters, resolvedLimit, "kt") + ? buildInventorySaleDocumentQuery(filters, resolvedLimit) : recipe.query_template === "inventory_purchase_to_sale_chain_profile" - ? buildInventoryMovementQuery(filters, resolvedLimit, "either") + ? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit) : recipe.query_template === "inventory_aging_by_purchase_date_profile" ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") : recipe.query_template === "contracts_by_counterparty_profile" diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index 02a6521..325bc17 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -848,6 +848,16 @@ function extractInventoryCounterpartyCandidates(row, excludedTokens = []) { } candidates.push(normalized); } + const explicitCounterparty = normalizeCounterpartyDisplayLabel(row.counterparty); + const explicitComparable = normalizeEntityToken(explicitCounterparty); + if (explicitCounterparty && + explicitComparable && + explicitComparable !== itemToken && + explicitComparable !== warehouseToken && + explicitComparable !== organizationToken && + !excludedComparableTokens.includes(explicitComparable)) { + candidates.unshift(explicitCounterparty); + } return uniqueStrings(candidates); } function summarizeInventoryTraceRows(rows, excludedCounterpartyTokens = []) { @@ -883,6 +893,7 @@ function summarizeInventoryTraceRows(rows, excludedCounterpartyTokens = []) { function formatInventoryTraceRows(rows, limit = 10, excludedCounterpartyTokens = []) { return rows.slice(0, limit).map((row, index) => { const parties = extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens); + const item = extractInventoryItemName(row); const warehouse = extractInventoryWarehouseName(row); const organization = extractInventoryOrganizationName(row); const amount = typeof row.amount === "number" && Number.isFinite(row.amount) ? formatMoneyRub(row.amount) : "сумма не указана"; @@ -891,6 +902,9 @@ function formatInventoryTraceRows(rows, limit = 10, excludedCounterpartyTokens = `дата: ${inventoryTraceDateLabel(row.period)}`, `сумма: ${amount}` ]; + if (item) { + parts.push(`товар: ${item}`); + } if (warehouse) { parts.push(`склад: ${warehouse}`); } diff --git a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js index 8255de9..43e4a32 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js @@ -3,6 +3,59 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.composeInventoryReply = composeInventoryReply; const replyContracts_1 = require("./replyContracts"); const inventoryReplyPresentation_1 = require("./inventoryReplyPresentation"); +function cleanupInventoryRequestedParty(value) { + const cleaned = String(value ?? "") + .replace(/\s*(?:->|=>|→)\s*(?:товар|позици|номенклатур|покупател|buyer|customer|item|product|sku)[\s\S]*$/iu, "") + .replace(/\s+(?:на\s+дату|по\s+состоянию|за\s+период)\b[\s\S]*$/iu, "") + .replace(/[«»"]/gu, "") + .replace(/[.,;:\s]+$/u, "") + .trim(); + return cleaned.length > 0 ? cleaned : null; +} +function extractRequestedInventoryParty(userMessage, role) { + const text = String(userMessage ?? ""); + const patterns = role === "supplier" + ? [ + /(?:от\s+поставщика|у\s+поставщика|поставщик(?:а|у|ом)?|supplier|vendor)\s+([^\r\n?]+?)(?=$|[?]|(?:\s*(?:->|=>|→)\s*(?:товар|позици|номенклатур|item|product|sku|покупател|buyer|customer)))/iu + ] + : [ + /(?:покупател(?:ь|я|ю|ем)?|buyer|customer|client)\s+([^\r\n?]+?)(?=$|[?]|(?:\s*(?:->|=>|→))|(?:\s+на\s+дату))/iu + ]; + for (const pattern of patterns) { + const match = text.match(pattern); + const candidate = match?.[1] ? cleanupInventoryRequestedParty(match[1]) : null; + if (candidate) { + return candidate; + } + } + return null; +} +function inventoryPartyComparableTokens(value) { + const stopWords = new Set(["ооо", "ао", "пао", "зао", "ип", "llc", "ltd", "inc", "corp"]); + return String(value ?? "") + .toLowerCase() + .replace(/ё/gu, "е") + .replace(/[^a-zа-я0-9]+/giu, " ") + .split(/\s+/u) + .map((token) => token.trim()) + .filter((token) => token.length >= 3 && !stopWords.has(token)); +} +function inventoryRequestedPartyMatches(requested, actualParties) { + if (!requested) { + return true; + } + const requestedTokens = inventoryPartyComparableTokens(requested); + if (requestedTokens.length === 0) { + return false; + } + return actualParties.some((actual) => { + const actualTokens = inventoryPartyComparableTokens(actual); + return requestedTokens.every((token) => actualTokens.includes(token)); + }); +} +function inventoryPartyListOrUnknown(parties) { + return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем"; +} function composeInventoryReply(intent, rows, options, deps) { if (intent === "inventory_on_hand_as_of_date") { const asOfDate = deps.resolvePayablesAsOfDate(options); @@ -163,6 +216,29 @@ function composeInventoryReply(intent, rows, options, deps) { const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); const summary = deps.summarizeInventoryTraceRows(purchaseRows); 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 ?? "")); + if (unresolvedSupplierQuestion) { + const directAnswerLine = unresolvedRows.length > 0 + ? `В текущем складском срезе найдено операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.` + : "В текущем складском срезе товары без явно выделенной привязки к поставщику в доступных данных не найдены."; + const lines = [directAnswerLine]; + (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что проверили:", [ + `Дата среза: ${deps.formatDateRu(asOfDate)}.`, + `Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`, + `Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`, + `Поставщиков, выделенных в остальных операциях: ${deps.formatNumberWithDots(summary.counterparties.length)}.` + ]); + (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [ + "Без партионного учета это проверка доступного закупочного следа по складскому срезу, а не юридическое доказательство владельца каждой партии." + ]); + if (unresolvedRows.length > 0) { + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции без явно выделенного поставщика:", deps.formatInventoryTraceRows(unresolvedRows, 12)); + } + else if (summary.counterparties.length > 0) { + lines.push(`- В доступном закупочном следе встречаются поставщики: ${summary.counterparties.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]}.` @@ -283,12 +359,25 @@ function composeInventoryReply(intent, rows, options, deps) { const purchaseSummary = deps.summarizeInventoryTraceRows(purchaseRows); const saleSummary = deps.summarizeInventoryTraceRows(saleRows); const itemLabel = purchaseSummary.item ?? saleSummary.item ?? "товар не определен"; - const directAnswerLine = purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1 - ? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.` - : `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`; + const requestedSupplier = extractRequestedInventoryParty(options.userMessage, "supplier"); + const requestedBuyer = extractRequestedInventoryParty(options.userMessage, "buyer"); + const supplierMatches = inventoryRequestedPartyMatches(requestedSupplier, purchaseSummary.counterparties); + const buyerMatches = inventoryRequestedPartyMatches(requestedBuyer, saleSummary.counterparties); + const mismatchParts = []; + if (requestedSupplier && purchaseRows.length > 0 && !supplierMatches) { + mismatchParts.push(`запрошенный поставщик ${requestedSupplier} не совпал с найденным поставщиком: ${inventoryPartyListOrUnknown(purchaseSummary.counterparties)}`); + } + 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 + ? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.` + : `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`; const lines = [directAnswerLine, "", "Подтверждение:"]; - lines.push(`- Закупочных движений на 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`); - lines.push(`- Движений выбытия со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`); + lines.push(`- Строк закупки на 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`); + lines.push(`- Строк продажи со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`); if (purchaseRows.length > 0 && saleRows.length > 0) { lines.push("- В доступных данных найдены обе стороны цепочки: поступление и последующее выбытие."); } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index 5980b8a..6bb9490 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -192,6 +192,29 @@ function readStateTransitionReasonCodes(input) { .map((item) => toNonEmptyString(item)) .filter((item) => Boolean(item)); } +function readStringArray(value) { + return Array.isArray(value) + ? value.map((item) => toNonEmptyString(item)).filter((item) => Boolean(item)) + : []; +} +function hasExactMatchedFactualAddressReply(input, entryPoint) { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status); + const truthMode = toNonEmptyString(input.addressRuntimeMeta?.truth_mode); + const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); + const bindingStatus = toNonEmptyString(input.addressRuntimeMeta?.capability_binding_status); + const bindingViolations = readStringArray(input.addressRuntimeMeta?.capability_binding_violations); + return Boolean(mcpCallStatus === "matched_non_empty" && + truthMode === "confirmed" && + selectedRecipe?.startsWith("address_") && + (bindingStatus === "bound" || bindingStatus === "bound_with_limits") && + bindingViolations.length === 0); +} function hasRuntimeAdjustedExactReply(input, entryPoint) { if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { return false; @@ -332,6 +355,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { const matchedFactualAddressContinuationTarget = hasMatchedFactualAddressContinuationTarget(input, entryPoint); const matchedFactualSuggestedIntentPivotTarget = hasMatchedFactualSuggestedIntentPivotTarget(input, entryPoint); const fullConfirmedFactualAddressReply = hasFullConfirmedFactualAddressReply(input, entryPoint); + const exactMatchedFactualAddressReply = hasExactMatchedFactualAddressReply(input, entryPoint); const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint); if (!entryPoint) { pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); @@ -363,6 +387,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { if (fullConfirmedFactualAddressReply) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_full_confirmed_factual_address_reply"); } + if (exactMatchedFactualAddressReply) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_exact_matched_factual_address_reply"); + } if (runtimeAdjustedExactReply) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_runtime_adjusted_exact_reply_over_stale_discovery_turn_meaning"); } @@ -387,6 +414,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { !matchedFactualAddressContinuationTarget && !matchedFactualSuggestedIntentPivotTarget && !fullConfirmedFactualAddressReply && + !exactMatchedFactualAddressReply && !runtimeAdjustedExactReply && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && diff --git a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js index 11a18be..889e156 100644 --- a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js @@ -112,8 +112,17 @@ function resolveAddressLaneProtectionArbitration(input) { const semanticDeepInvestigationHintDetected = semanticGuardHints?.deep_investigation_signal_detected === true; const semanticAggregateShapeDetected = semanticExtraction?.query_shape === "AGGREGATE_LOOKUP" || semanticExtraction?.aggregation_profile === "management_profile"; + const exactSupportedIntentProtectedFromDeepPreference = Boolean(supportedAddressIntentDetected && + resolvedIntent && + ADDRESS_INTENTS_ALLOW_STRICT_DEEP_INVESTIGATION_BYPASS.has(resolvedIntent) && + semanticApplyCanonicalRecommended && + (!strictDeepInvestigationCueDetected || strictDeepInvestigationBypassAllowed)); + const unsupportedAggregateFollowupOverride = Boolean(followupContext && + llmContractMode === "unsupported" && + (semanticAggregateShapeDetected || !semanticApplyCanonicalRecommended) && + !exactSupportedIntentProtectedFromDeepPreference); const followupSemanticOverrideToDeepAllowed = Boolean(followupContext && - !supportedAddressIntentDetected && + (!supportedAddressIntentDetected || unsupportedAggregateFollowupOverride) && (rootContextOnlyFollowup || llmContractMode === "unsupported" || semanticAggregateShapeDetected || @@ -127,11 +136,16 @@ function resolveAddressLaneProtectionArbitration(input) { !deepAnalysisPreferenceDetected && !strictDeepInvestigationCueDetected && !semanticAggregateShapeDetected); + const unsupportedSpecificLlmIntent = Boolean(llmContractMode === "unsupported" && + llmContractIntent && + llmContractIntent !== "unknown"); const protectAddressLaneFromFallback = Boolean(supportedAddressRouteCandidateDetected && - !deepAnalysisPreferenceDetected && - (exactAddressIntentProtectedFromSemanticDeepHint || - !semanticDeepInvestigationHintDetected || - strictDeepInvestigationBypassAllowed)); + (exactSupportedIntentProtectedFromDeepPreference || + (!unsupportedSpecificLlmIntent && + !deepAnalysisPreferenceDetected && + (exactAddressIntentProtectedFromSemanticDeepHint || + !semanticDeepInvestigationHintDetected || + strictDeepInvestigationBypassAllowed)))); return { supportedAddressIntentDetected, supportedAddressRouteCandidateDetected, @@ -139,6 +153,7 @@ function resolveAddressLaneProtectionArbitration(input) { semanticAggregateShapeDetected, followupSemanticOverrideToDeepAllowed, exactAddressIntentProtectedFromSemanticDeepHint, + exactSupportedIntentProtectedFromDeepPreference, protectAddressLaneFromFallback }; } diff --git a/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js b/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js index 7014751..df94e6b 100644 --- a/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js +++ b/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js @@ -282,7 +282,7 @@ exports.INVENTORY_CAPABILITY_CONTRACTS = [ entry_modes: ["root_entry", "root_followup", "clarification_resume"], transitions: ["T1", "T2", "T7"], requiresFocusObject: false, - requiredAnchors: ["supplier"], + requiredAnchors: [], resultShape: "supplier_to_stock_item_overlap", answerObjectShape: "inventory_supplier_overlap", bundleReusePolicy: "none", diff --git a/llm_normalizer/backend/src/services/addressCoverageEvidencePolicy.ts b/llm_normalizer/backend/src/services/addressCoverageEvidencePolicy.ts index 3752809..06bdb77 100644 --- a/llm_normalizer/backend/src/services/addressCoverageEvidencePolicy.ts +++ b/llm_normalizer/backend/src/services/addressCoverageEvidencePolicy.ts @@ -162,6 +162,12 @@ export function resolveAddressAsOfDateBasis( filters: AddressFilterSet, semanticFrame?: AddressSemanticFrame | null ): AddressAsOfDateBasis | null { + if ( + semanticFrame?.date_scope_kind === "implicit_current" && + semanticFrame.date_basis_hint === "implicit_current_snapshot" + ) { + return "implicit_current_snapshot"; + } const asOfDate = normalizeIsoDateHint(filters.as_of_date); if (asOfDate) { return "explicit_as_of_date"; diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 5923876..5d10783 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -181,12 +181,14 @@ function toIsoDate(year: number, month: number, day: number): string | null { return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } +function hasImplicitCurrentAsOfDateCue(text: string): boolean { + return /\b(сегодня|на\s+сегодня|на\s+текущ(?:ую|ая|ий|ее|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодняшн(?:юю|ий|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+текущ(?:ий|ую)\s+момент|today|as\s+of\s+today|current\s+date|as\s+of\s+current\s+date)\b/i.test( + text + ); +} + function extractAsOfDate(text: string): string | undefined { - if ( - /\b(сегодня|на\s+сегодня|на\s+текущ(?:ую|ая|ий|ее|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодняшн(?:юю|ий|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+текущ(?:ий|ую)\s+момент|today|as\s+of\s+today|current\s+date|as\s+of\s+current\s+date)\b/i.test( - text - ) - ) { + if (hasImplicitCurrentAsOfDateCue(text)) { return new Date().toISOString().slice(0, 10); } @@ -751,6 +753,9 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean { "ноябрь", "декабрь" ]); + const isLowQualityTimeToken = (token: string): boolean => + lowQualityTimeTokens.has(token) || + /^(?:январ|феврал|март|апрел|ма(?:й|я|е)|июн|июл|август|сентябр|октябр|ноябр|декабр)/iu.test(token); const lowQualityGenericTokens = new Set([ "деньги", "денег", @@ -776,7 +781,7 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean { const meaningfulNonTemporalTokens = tokens.filter( (token) => isLikelyCounterpartyToken(token) && - !lowQualityTimeTokens.has(token) && + !isLowQualityTimeToken(token) && !/^(?:19|20)\d{2}$/.test(token) ); if (meaningfulNonTemporalTokens.length === 0 && hasTemporalCue) { @@ -785,7 +790,7 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean { const meaningfulNonGenericTokens = tokens.filter( (token) => isLikelyCounterpartyToken(token) && - !lowQualityTimeTokens.has(token) && + !isLowQualityTimeToken(token) && !lowQualityGenericTokens.has(token) && !/^(?:19|20)\d{2}$/.test(token) ); @@ -1302,6 +1307,9 @@ function isTemporalWarehousePhrase(candidate: string): boolean { .toLowerCase() .replace(/ё/g, "е") .trim(); + if (/^(?:в|на)?\s*(?:сейчас|сегодня|текущ(?:ий|ую|ем|его)\s+момент|данн(?:ый|ую|ом|ого)\s+момент)$/iu.test(normalized)) { + return true; + } if (/^(?:на\s+)?(?:эту|ту|текущ(?:ую|ая|ий|ее|ей)|сегодняшн(?:юю|ая|ий|ее|ей))(?:\s+же)?\s+дат(?:у|а|е|ой)$/iu.test(normalized)) { return true; } @@ -1355,6 +1363,12 @@ function isLowQualityWarehouseAnchorValue(rawValue: string): boolean { "лежали", "на", "по", + "компания", + "компании", + "компанию", + "организация", + "организации", + "организацию", "складе", "складу", "складом", @@ -1373,7 +1387,11 @@ function isLowQualityWarehouseAnchorValue(rawValue: string): boolean { if (tokens.length === 0) { return true; } - const meaningfulTokens = tokens.filter((token) => !lowQualityTokens.has(token) && token.length > 1); + const isLowQualityWarehouseToken = (token: string): boolean => + lowQualityTokens.has(token) || + /^(?:19|20)\d{2}$/.test(token) || + /^(?:январ|феврал|март|апрел|ма(?:й|я|е)|июн|июл|август|сентябр|октябр|ноябр|декабр)/iu.test(token); + const meaningfulTokens = tokens.filter((token) => !isLowQualityWarehouseToken(token) && token.length > 1); if (meaningfulTokens.length === 0) { return true; } @@ -1453,16 +1471,18 @@ function extractInventoryWarehouseAnchor(text: string): string | undefined { function extractInventorySupplierAnchor(text: string): string | undefined { const match = String(text ?? "").match( - /(?:от\s+поставщика|у\s+поставщика|поставщика|поставщику)\s+([^\r\n?]+?)(?=$|[?])/iu + /(?:от\s+поставщика|у\s+поставщика|поставщик(?:а|у|ом)?|supplier|vendor)\s+([^\r\n?]+?)(?=$|[?])/iu ); if (!match?.[1]) { return undefined; } const candidate = cleanupAnchorValue( - cleanupAnchorValue(String(match[1])).replace( - /\s+(?:сейчас|на\s+склад(?:е|у|ом)?|на\s+дату|по\s+состоянию\s+на\s+дату|котор(?:ый|ые|ых)|куплен(?:ы|а|о)?|были|был|лежат|лежит|еще|ещё|организац\w*|компани\w*).*$/iu, - "" - ) + cleanupAnchorValue(String(match[1])) + .replace( + /\s+(?:сейчас|на\s+склад(?:е|у|ом)?|на\s+дату|по\s+состоянию\s+на\s+дату|котор(?:ый|ые|ых)|куплен(?:ы|а|о)?|были|был|лежат|лежит|еще|ещё|организац\w*|компани\w*).*$/iu, + "" + ) + .replace(/\s*(?:->|=>|→)\s*(?:товар|позици|номенклатур|item|product|sku)[\s\S]*$/iu, "") ); if ( !candidate || @@ -1595,7 +1615,7 @@ function resolveSemanticDateScopeKind( } function resolveSemanticDateBasisHint(filters: AddressFilterSet, warnings: string[]): AddressSemanticFrame["date_basis_hint"] { - if (warnings.includes("as_of_date_defaulted_today")) { + if (warnings.includes("as_of_date_defaulted_today") || warnings.includes("as_of_date_from_implicit_current_phrase")) { return "implicit_current_snapshot"; } const hasAsOfDate = typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0; @@ -1710,6 +1730,7 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent } } const warnings: string[] = []; + const implicitCurrentAsOfDateCue = hasImplicitCurrentAsOfDateCue(text); const explicitAsOfDate = extractAsOfDate(text); const explicitAsOfDateWithCue = extractAsOfDateWithCue(text); @@ -1756,6 +1777,13 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent filters.counterparty = supplierAnchor; } } + if (intent === "inventory_purchase_to_sale_chain") { + const supplierAnchor = asksForInventorySupplierIdentity(text) ? undefined : extractInventorySupplierAnchor(text); + if (supplierAnchor) { + filters.counterparty = supplierAnchor; + warnings.push("supplier_anchor_derived_for_inventory_documentary_chain"); + } + } const allowGenericCounterpartyAnchor = !isInventoryTraceIntent(intent); const counterpartyMatch = allowGenericCounterpartyAnchor ? text.match(COUNTERPARTY_PATTERN) : null; @@ -1923,6 +1951,9 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent if (usesAsOfPrimaryWindow(intent) && explicitAsOfDate) { filters.as_of_date = explicitAsOfDate; + if (implicitCurrentAsOfDateCue && !warnings.includes("as_of_date_from_implicit_current_phrase")) { + warnings.push("as_of_date_from_implicit_current_phrase"); + } const periodWasDerivedHeuristically = warnings.includes("period_derived_from_month_phrase") || warnings.includes("period_derived_from_year_range_phrase") || @@ -1941,6 +1972,9 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent const asOfDate = extractAsOfDate(text); if (asOfDate) { filters.as_of_date = asOfDate; + if (implicitCurrentAsOfDateCue && !warnings.includes("as_of_date_from_implicit_current_phrase")) { + warnings.push("as_of_date_from_implicit_current_phrase"); + } } } diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index f1d3c3a..5775802 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -1732,6 +1732,18 @@ function hasInventoryPurchaseDocumentsSignalV2(text: string): boolean { } function hasInventorySaleTraceSignalV2(text: string): boolean { + const value = String(text ?? ""); + const hasPlainItemCue = + /(?:\u0442\u043e\u0432\u0430\u0440|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440|\u043f\u043e\u0437\u0438\u0446|\u043f\u0440\u043e\u0434\u0443\u043a\u0446\u0438|sku|item|product)/iu.test( + value + ); + const hasPlainTraceCue = + /(?:\u043a\u043e\u043c\u0443\s+(?:\u0432\s+\u0438\u0442\u043e\u0433\u0435\s+)?(?:\u043c\u044b\s+)?(?:\u043f\u0440\u043e\u0434\u0430\u043b\u0438|\u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043b\u0438|\u0432\u043f\u0430\u0440\u0438\u043b\u0438)|\u043a\u043e\u043c\u0443\s+(?:\u0431\u044b\u043b[\u0430\u0438\u043e]?|\u0431\u044b\u043b\u0438)?\s*(?:\u043f\u0440\u043e\u0434\u0430\u043d|\u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d)|\u043a\u0442\u043e\s+\u043a\u0443\u043f\u0438\u043b|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|buyer|sale\s+trace|trace\s+of\s+sale)/iu.test( + value + ); + if (hasPlainItemCue && (hasPlainTraceCue || hasInventorySaleCue(value))) { + return true; + } const hasItemCue = /(?:товар|номенклатур|sku|item|product|позици(?:СЏ|СЋ|Рё)|продукци(?:СЏ|СЋ|Рё))/iu.test(text); const hasTraceCue = /(?:РєРѕРјСѓ\s+(?:РІ\s+итоге\s+)?(?:РјС‹\s+)?продали|РєРѕРјСѓ\s+был\s+продан|РєСѓРґР°\s+(?:РІ\s+итоге\s+)?(?:РјС‹\s+)?продали(?:\s+(?:это|его|товар|позицию))?|РєСѓРґР°\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+РєСѓРїРёР»|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+РїСЂРѕС€[её]Р»\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test( @@ -1766,6 +1778,15 @@ function hasInventoryAgingSignal(text: string): boolean { } function hasInventoryPurchaseToSaleChainSignal(text: string): boolean { + const value = String(text ?? ""); + const hasPlainItemCue = /(?:товар|номенклатур|позици|sku|item|product)/iu.test(value); + const hasPlainChainCue = + /(?:закупк[а-яё]*\s*->\s*склад\s*->\s*продаж|закупк[а-яё]*[\s\S]{0,80}склад[\s\S]{0,80}продаж|через\s+какие\s+документы\s+прош[её]л\s+путь|путь\s+товар[а-яё]*[\s\S]{0,80}закуп|цепочк[а-яё]*\s+движен|документально\s+подтвержденн[а-яё]*\s+цепочк|supplier\s*->\s*item\s*->\s*(?:buyer|customer)|supplier\s+to\s+buyer|supplier\s+to\s+item\s+to\s+buyer|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale)/iu.test( + value + ) || value.includes("->"); + if (hasPlainItemCue && hasPlainChainCue) { + return true; + } const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); const hasChainCue = /(?:закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|закупка\s*->\s*склад\s*->\s*продажа|цепочк[аи]\s+движен|документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*(?:buyer|customer)|supplier\s+to\s+buyer|supplier\s+to\s+item\s+to\s+buyer)/iu.test( @@ -2030,6 +2051,17 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio const hasRankingCue = /(?:топ|ранк|сам(?:ый|ая|ое|ые)|больше\s+всего|наибольш|крупн|жирн|max|top|rank)/iu.test( normalized ); + const hasInventoryPurchaseToSaleDocumentChainCue = + /(?:закупк[а-яё]*[\s\S]{0,80}склад[\s\S]{0,80}продаж|путь\s+товар[а-яё]*[\s\S]{0,80}закуп|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|->\s*(?:склад|warehouse|stock)\s*->\s*(?:продаж|sale))/iu.test( + normalized + ) && /(?:товар|позици|номенклатур|sku|item|product)/iu.test(normalized); + if (hasInventoryPurchaseToSaleDocumentChainCue) { + return unicodeBridgeResolution( + "inventory_purchase_to_sale_chain", + "high", + "unicode_inventory_purchase_to_sale_chain_bridge_signal_detected" + ); + } const hasOpenItemsAccountCue = /(?:хвост|долг|незакрыт|вис)/iu.test(normalized) && diff --git a/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts b/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts index 7fa97eb..4904ec7 100644 --- a/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts +++ b/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts @@ -146,13 +146,21 @@ function hasInventoryProvenanceSignalV2(text: string): boolean { } function hasInventoryPurchaseDateSignal(text: string): boolean { + const value = String(text ?? ""); const hasItemCue = - /(?:товар|номенклатур|sku|item|product)/iu.test(text) || hasSelectedObjectInventoryCue(text); - const hasPurchaseDateCue = - /(?:РєРѕРіРґР°\s+(?:примерно\s+)?(?:РјС‹\s+)?купили|РєРѕРіРґР°\s+был\s+куплен|РєРѕРіРґР°\s+куплен|дата\s+закупк|purchase\s+date)/iu.test( - text + /(?:\u0442\u043e\u0432\u0430\u0440|\u043f\u043e\u0437\u0438\u0446|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440|sku|item|product)/iu.test( + value ) || - /(?:РєРѕРіРґР°\s+был(?:Р°|Рё|Рѕ)?\s+закупк\w*|РєРѕРіРґР°\s+закупк\w*)/iu.test(text); + /(?:товар|номенклатур|sku|item|product)/iu.test(value) || + hasSelectedObjectInventoryCue(value); + const hasPurchaseDateCue = + /(?:\u043a\u043e\u0433\u0434\u0430\s+(?:\u043f\u0440\u0438\u043c\u0435\u0440\u043d\u043e\s+)?(?:\u043c\u044b\s+)?\u043a\u0443\u043f\u0438\u043b\u0438|\u043a\u043e\u0433\u0434\u0430\s+\u0431\u044b\u043b(?:\u0430|\u0438|\u043e)?\s+\u043a\u0443\u043f\u043b\u0435\u043d|\u043a\u043e\u0433\u0434\u0430\s+\u043a\u0443\u043f\u043b\u0435\u043d|\u0434\u0430\u0442\u0430\s+\u0437\u0430\u043a\u0443\u043f\u043a|purchase\s+date)/iu.test( + value + ) || + /(?:РєРѕРіРґР°\s+(?:примерно\s+)?(?:РјС‹\s+)?купили|РєРѕРіРґР°\s+был\s+куплен|РєРѕРіРґР°\s+куплен|дата\s+закупк|purchase\s+date)/iu.test( + value + ) || + /(?:РєРѕРіРґР°\s+был(?:Р°|Рё|Рѕ)?\s+закупк\w*|РєРѕРіРґР°\s+закупк\w*)/iu.test(value); return hasItemCue && hasPurchaseDateCue; } @@ -177,7 +185,7 @@ function hasInventorySaleTraceSignalV2(text: string): boolean { const value = String(text ?? ""); const hasPlainItemCue = /(?:товар|номенклатур|позици|продукци|sku|item|product)/iu.test(value); const hasPlainTraceCue = - /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?(?:продали|реализовали|впарили)|кому\s+(?:был[аио]?|были)?\s*реализован|кто\s+купил|покупател|buyer|sale\s+trace|trace\s+of\s+sale)/iu.test( + /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?(?:продали|реализовали|впарили)|кому\s+(?:был[аио]?|были)?\s*(?:продан|реализован)|кто\s+купил|покупател|buyer|sale\s+trace|trace\s+of\s+sale)/iu.test( value ); if (hasPlainItemCue && hasPlainTraceCue) { diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 0969fcc..e36f3e2 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -86,6 +86,8 @@ interface NormalizedAddressRow { item?: string | null; warehouse?: string | null; organization?: string | null; + counterparty?: string | null; + contract?: string | null; } interface AddressTryHandleOptions { @@ -350,6 +352,21 @@ function deriveTaxQuarterWindowForDate(value: unknown): { period_from: string; p }; } +function deriveMonthWindowForDate(value: unknown): { period_from: string; period_to: string } | null { + const isoDate = normalizeIsoDateForQuery(value); + if (!isoDate) { + return null; + } + const match = isoDate.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + return { + period_from: `${match[1]}-${match[2]}-01`, + period_to: isoDate + }; +} + function toDateTimeExprForQuery(isoDate: string): string | null { const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!match) { @@ -1446,6 +1463,8 @@ function toNormalizedRows(rows: Array>): NormalizedAddre row.organization_name, row.ОрганизацияПредставление ); + const counterparty = firstNonEmptyString(row.Контрагент, row.Counterparty, row.counterparty); + const contract = firstNonEmptyString(row.Договор, row.Contract, row.contract); const analytics = collectAnalyticsStrings(row); return { @@ -1458,7 +1477,9 @@ function toNormalizedRows(rows: Array>): NormalizedAddre quantity, item, warehouse, - organization + organization, + counterparty, + contract }; }) .filter((item) => Boolean(item.period || item.registrator)); @@ -1526,6 +1547,10 @@ function formatMoneyRubForReply(value: number): string { } function extractContractNameFromNormalizedRow(row: NormalizedAddressRow): string | null { + const explicitContract = firstNonEmptyString(row.contract); + if (explicitContract) { + return explicitContract; + } for (const token of row.analytics) { const normalized = String(token ?? "").trim(); if (!normalized) { @@ -1898,6 +1923,11 @@ function applyPreExecutionOrganizationScopeGrounding(input: { ]); const resolvedOrganizationFromMessage = resolveOrganizationSelectionFromMessage(input.userMessage, candidateOrganizations); const referentialOrganizationScopeDetected = hasReferentialOrganizationScopeSignal(input.userMessage); + const counterpartyAnchorProtectsOrganizationScope = + input.semanticFrame?.anchor_kind === "counterparty" && + typeof input.filters.counterparty === "string" && + input.filters.counterparty.trim().length > 0 && + !referentialOrganizationScopeDetected; if ( !input.filters.organization && @@ -1916,6 +1946,7 @@ function applyPreExecutionOrganizationScopeGrounding(input: { if ( resolvedOrganizationFromMessage && (!input.filters.organization || input.semanticFrame?.anchor_kind === "organization") && + !counterpartyAnchorProtectsOrganizationScope && !sameNormalizedOrganizationScope(input.filters.organization ?? null, resolvedOrganizationFromMessage) ) { input.filters.organization = resolvedOrganizationFromMessage; @@ -2377,6 +2408,12 @@ function hasExplicitPeriodWindow(filters: AddressFilterSet): boolean { ); } +function asksForUnresolvedInventorySupplierLink(userMessage: string | null | undefined): boolean { + 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 canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilterSet): boolean { const hasRecoverableAsOfOnlyWindow = !hasExplicitPeriodWindow(filters) && @@ -2426,6 +2463,7 @@ function shouldDetachLifecycleExecutionFromSnapshotContext( reasons: string[] ): boolean { if ( + intent !== "inventory_supplier_stock_overlap_as_of_date" && intent !== "inventory_sale_trace_for_item" && intent !== "inventory_purchase_to_sale_chain" ) { @@ -2433,6 +2471,8 @@ function shouldDetachLifecycleExecutionFromSnapshotContext( } return ( + 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") || reasons.includes("as_of_date_from_open_items_followup_context") @@ -3506,6 +3546,91 @@ export class AddressQueryService { }); const knownOrganizations = mergeKnownOrganizations(options.knownOrganizations ?? []); const activeOrganization = normalizeOrganizationScopeValue(options.activeOrganization ?? null); + const previousOrganizationFromContext = normalizeOrganizationScopeValue(followupContext?.previous_filters?.organization ?? null); + const chainCounterpartyAnchor = toNonEmptyFilterValue(filters.extracted_filters.counterparty); + const chainOrganizationAnchor = toNonEmptyFilterValue(filters.extracted_filters.organization); + if ( + intent.intent === "inventory_purchase_to_sale_chain" && + chainCounterpartyAnchor && + chainOrganizationAnchor && + sameOrganizationEntityReference(chainOrganizationAnchor, chainCounterpartyAnchor) + ) { + delete filters.extracted_filters.organization; + const restoredOrganization = activeOrganization ?? previousOrganizationFromContext; + if (restoredOrganization && !sameOrganizationEntityReference(restoredOrganization, chainCounterpartyAnchor)) { + filters.extracted_filters.organization = restoredOrganization; + if (!filters.warnings.includes("organization_restored_from_inventory_chain_context")) { + filters.warnings.push("organization_restored_from_inventory_chain_context"); + } + if (!baseReasons.includes("organization_restored_from_inventory_chain_context")) { + baseReasons.push("organization_restored_from_inventory_chain_context"); + } + } else { + if (!filters.warnings.includes("organization_cleared_from_inventory_chain_counterparty_anchor")) { + filters.warnings.push("organization_cleared_from_inventory_chain_counterparty_anchor"); + } + if (!baseReasons.includes("organization_cleared_from_inventory_chain_counterparty_anchor")) { + baseReasons.push("organization_cleared_from_inventory_chain_counterparty_anchor"); + } + } + } + if ( + intent.intent === "inventory_supplier_stock_overlap_as_of_date" && + (followupContext?.root_filters || followupContext?.previous_filters) && + !toNonEmptyFilterValue(filters.extracted_filters.period_from) && + !toNonEmptyFilterValue(filters.extracted_filters.period_to) && + !/(?:за\s+вс[её]\s+время|за\s+любой\s+период|all[\s-]?time|all\s+periods?)/iu.test(userMessage) + ) { + const snapshotContextFilters = + followupContext?.root_filters && + (toNonEmptyFilterValue(followupContext.root_filters.period_from) || + toNonEmptyFilterValue(followupContext.root_filters.period_to)) + ? followupContext.root_filters + : followupContext?.previous_filters; + const previousPeriodFrom = toNonEmptyFilterValue(snapshotContextFilters?.period_from); + const previousPeriodTo = toNonEmptyFilterValue(snapshotContextFilters?.period_to); + if (previousPeriodFrom || previousPeriodTo) { + filters.extracted_filters = { + ...filters.extracted_filters, + ...(previousPeriodFrom ? { period_from: previousPeriodFrom } : {}), + ...(previousPeriodTo ? { period_to: previousPeriodTo } : {}) + }; + if (!toNonEmptyFilterValue(filters.extracted_filters.as_of_date)) { + const inheritedAsOfDate = + toNonEmptyFilterValue(snapshotContextFilters?.as_of_date) ?? previousPeriodTo ?? previousPeriodFrom; + if (inheritedAsOfDate) { + filters.extracted_filters.as_of_date = inheritedAsOfDate; + } + } + if (!filters.warnings.includes("period_window_semantic_from_inventory_snapshot_context")) { + filters.warnings.push("period_window_semantic_from_inventory_snapshot_context"); + } + if (!baseReasons.includes("period_window_semantic_from_inventory_snapshot_context")) { + baseReasons.push("period_window_semantic_from_inventory_snapshot_context"); + } + } + } + if ( + intent.intent === "inventory_supplier_stock_overlap_as_of_date" && + !toNonEmptyFilterValue(filters.extracted_filters.period_from) && + !toNonEmptyFilterValue(filters.extracted_filters.period_to) && + asksForUnresolvedInventorySupplierLink(userMessage) && + !/(?:Р·Р°\s+РІСЃ[её]\s+время|Р·Р°\s+любой\s+период|all[\s-]?time|all\s+periods?)/iu.test(userMessage) + ) { + const monthWindow = deriveMonthWindowForDate(filters.extracted_filters.as_of_date); + if (monthWindow) { + filters.extracted_filters = { + ...filters.extracted_filters, + ...monthWindow + }; + if (!filters.warnings.includes("period_window_semantic_from_inventory_as_of_month")) { + filters.warnings.push("period_window_semantic_from_inventory_as_of_month"); + } + if (!baseReasons.includes("period_window_semantic_from_inventory_as_of_month")) { + baseReasons.push("period_window_semantic_from_inventory_as_of_month"); + } + } + } if ( isOrganizationScopedValueFlowIntent(intent.intent) && hasExplicitSingleOrganizationValueFlowScopeRequest(userMessage) && @@ -3678,6 +3803,7 @@ export class AddressQueryService { const detachedExecutionFilters: AddressFilterSet = { ...executionFilters }; let periodDetached = false; let asOfDetached = false; + const keepAsOfDateForInventorySnapshotOverlap = intent.intent === "inventory_supplier_stock_overlap_as_of_date"; if ( toNonEmptyFilterValue(detachedExecutionFilters.period_from) || toNonEmptyFilterValue(detachedExecutionFilters.period_to) @@ -3686,7 +3812,7 @@ export class AddressQueryService { delete detachedExecutionFilters.period_to; periodDetached = true; } - if (toNonEmptyFilterValue(detachedExecutionFilters.as_of_date)) { + if (!keepAsOfDateForInventorySnapshotOverlap && toNonEmptyFilterValue(detachedExecutionFilters.as_of_date)) { delete detachedExecutionFilters.as_of_date; asOfDetached = true; } @@ -4231,6 +4357,20 @@ export class AddressQueryService { : anchor.anchor_type === "contract" && anchor.anchor_value_resolved ? { ...executionFilters, contract: anchor.anchor_value_resolved } : executionFilters; + if ( + intent.intent === "inventory_purchase_to_sale_chain" && + toNonEmptyFilterValue(filtersForMatching.item) && + toNonEmptyFilterValue(filtersForMatching.counterparty) + ) { + filtersForMatching = { ...filtersForMatching }; + delete filtersForMatching.counterparty; + if (!filters.warnings.includes("inventory_chain_counterparty_anchor_kept_for_verification")) { + filters.warnings.push("inventory_chain_counterparty_anchor_kept_for_verification"); + } + if (!baseReasons.includes("inventory_chain_counterparty_anchor_kept_for_verification")) { + baseReasons.push("inventory_chain_counterparty_anchor_kept_for_verification"); + } + } const accountScopeAudit = buildAccountScopeAudit({ intent: intent.intent, filters: filtersForMatching, diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index b56e330..c9f4c1d 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -1334,6 +1334,29 @@ function buildInventoryPurchaseDocumentQuery(filters: AddressFilterSet, resolved .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); } +function stripTrailingOrderBy(query: string): string { + return String(query ?? "").replace(/\r?\nУПОРЯДОЧИТЬ ПО[\s\S]*$/u, "").trimEnd(); +} + +function removeTopLimit(query: string): string { + return String(query ?? "").replace(/ВЫБРАТЬ ПЕРВЫЕ\s+\d+/u, "ВЫБРАТЬ"); +} + +function buildInventoryPurchaseToSaleDocumentQuery(filters: AddressFilterSet, resolvedLimit: number): string { + const purchaseQuery = removeTopLimit(stripTrailingOrderBy(buildInventoryPurchaseDocumentQuery(filters, resolvedLimit))); + const saleQuery = removeTopLimit(stripTrailingOrderBy(buildInventorySaleDocumentQuery(filters, resolvedLimit))); + return [ + purchaseQuery, + "", + "ОБЪЕДИНИТЬ ВСЕ", + "", + saleQuery, + "", + "УПОРЯДОЧИТЬ ПО", + ` Период ${resolveOrderDirection(filters.sort)}` + ].join("\n"); +} + export function buildCounterpartyPurchaseDocumentQuery(filters: AddressFilterSet, resolvedLimit: number): string { const goodsCounterpartyCondition = buildCounterpartyReferenceCondition(filters, ["Товары.Ссылка.Контрагент"]); const servicesCounterpartyCondition = buildCounterpartyReferenceCondition(filters, ["Услуги.Ссылка.Контрагент"]); @@ -1628,9 +1651,9 @@ export function buildAddressRecipePlan( : recipe.query_template === "inventory_supplier_stock_overlap_profile" ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") : recipe.query_template === "inventory_sale_trace_profile" - ? buildInventoryMovementQuery(filters, resolvedLimit, "kt") + ? buildInventorySaleDocumentQuery(filters, resolvedLimit) : recipe.query_template === "inventory_purchase_to_sale_chain_profile" - ? buildInventoryMovementQuery(filters, resolvedLimit, "either") + ? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit) : recipe.query_template === "inventory_aging_by_purchase_date_profile" ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") : recipe.query_template === "contracts_by_counterparty_profile" diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index d65e1df..f12c334 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -29,6 +29,8 @@ export interface ComposeStageRow { item?: string | null; warehouse?: string | null; organization?: string | null; + counterparty?: string | null; + contract?: string | null; } export interface VatDirectSourceProbeItem { @@ -1098,6 +1100,18 @@ function extractInventoryCounterpartyCandidates(row: ComposeStageRow, excludedTo } candidates.push(normalized); } + const explicitCounterparty = normalizeCounterpartyDisplayLabel(row.counterparty); + const explicitComparable = normalizeEntityToken(explicitCounterparty); + if ( + explicitCounterparty && + explicitComparable && + explicitComparable !== itemToken && + explicitComparable !== warehouseToken && + explicitComparable !== organizationToken && + !excludedComparableTokens.includes(explicitComparable) + ) { + candidates.unshift(explicitCounterparty); + } return uniqueStrings(candidates); } @@ -1156,6 +1170,7 @@ function summarizeInventoryTraceRows(rows: ComposeStageRow[], excludedCounterpar function formatInventoryTraceRows(rows: ComposeStageRow[], limit = 10, excludedCounterpartyTokens: string[] = []): string[] { return rows.slice(0, limit).map((row, index) => { const parties = extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens); + const item = extractInventoryItemName(row); const warehouse = extractInventoryWarehouseName(row); const organization = extractInventoryOrganizationName(row); const amount = @@ -1165,6 +1180,9 @@ function formatInventoryTraceRows(rows: ComposeStageRow[], limit = 10, excludedC `дата: ${inventoryTraceDateLabel(row.period)}`, `сумма: ${amount}` ]; + if (item) { + parts.push(`товар: ${item}`); + } if (warehouse) { parts.push(`склад: ${warehouse}`); } diff --git a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts index 2580d49..b68c5f4 100644 --- a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts @@ -73,6 +73,65 @@ interface InventoryReplyDeps { isInventorySaleMovement: (row: ComposeStageRow) => boolean; } +function cleanupInventoryRequestedParty(value: string): string | null { + const cleaned = String(value ?? "") + .replace(/\s*(?:->|=>|→)\s*(?:товар|позици|номенклатур|покупател|buyer|customer|item|product|sku)[\s\S]*$/iu, "") + .replace(/\s+(?:на\s+дату|по\s+состоянию|за\s+период)\b[\s\S]*$/iu, "") + .replace(/[«»"]/gu, "") + .replace(/[.,;:\s]+$/u, "") + .trim(); + return cleaned.length > 0 ? cleaned : null; +} + +function extractRequestedInventoryParty(userMessage: string | null | undefined, role: "supplier" | "buyer"): string | null { + const text = String(userMessage ?? ""); + const patterns = + role === "supplier" + ? [ + /(?:от\s+поставщика|у\s+поставщика|поставщик(?:а|у|ом)?|supplier|vendor)\s+([^\r\n?]+?)(?=$|[?]|(?:\s*(?:->|=>|→)\s*(?:товар|позици|номенклатур|item|product|sku|покупател|buyer|customer)))/iu + ] + : [ + /(?:покупател(?:ь|я|ю|ем)?|buyer|customer|client)\s+([^\r\n?]+?)(?=$|[?]|(?:\s*(?:->|=>|→))|(?:\s+на\s+дату))/iu + ]; + for (const pattern of patterns) { + const match = text.match(pattern); + const candidate = match?.[1] ? cleanupInventoryRequestedParty(match[1]) : null; + if (candidate) { + return candidate; + } + } + return null; +} + +function inventoryPartyComparableTokens(value: string | null | undefined): string[] { + const stopWords = new Set(["ооо", "ао", "пао", "зао", "ип", "llc", "ltd", "inc", "corp"]); + return String(value ?? "") + .toLowerCase() + .replace(/ё/gu, "е") + .replace(/[^a-zа-я0-9]+/giu, " ") + .split(/\s+/u) + .map((token) => token.trim()) + .filter((token) => token.length >= 3 && !stopWords.has(token)); +} + +function inventoryRequestedPartyMatches(requested: string | null, actualParties: string[]): boolean { + if (!requested) { + return true; + } + const requestedTokens = inventoryPartyComparableTokens(requested); + if (requestedTokens.length === 0) { + return false; + } + return actualParties.some((actual) => { + const actualTokens = inventoryPartyComparableTokens(actual); + return requestedTokens.every((token) => actualTokens.includes(token)); + }); +} + +function inventoryPartyListOrUnknown(parties: string[]): string { + return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем"; +} + export function composeInventoryReply( intent: AddressIntent, rows: ComposeStageRow[], @@ -263,6 +322,39 @@ export function composeInventoryReply( const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); const summary = deps.summarizeInventoryTraceRows(purchaseRows); 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 ?? "") + ); + if (unresolvedSupplierQuestion) { + const directAnswerLine = + unresolvedRows.length > 0 + ? `В текущем складском срезе найдено операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.` + : "В текущем складском срезе товары без явно выделенной привязки к поставщику в доступных данных не найдены."; + const lines: string[] = [directAnswerLine]; + appendInventoryBulletSection(lines, "Что проверили:", [ + `Дата среза: ${deps.formatDateRu(asOfDate)}.`, + `Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`, + `Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`, + `Поставщиков, выделенных в остальных операциях: ${deps.formatNumberWithDots(summary.counterparties.length)}.` + ]); + appendInventoryBulletSection(lines, "Ограничения:", [ + "Без партионного учета это проверка доступного закупочного следа по складскому срезу, а не юридическое доказательство владельца каждой партии." + ]); + if (unresolvedRows.length > 0) { + appendInventorySection( + lines, + "Позиции без явно выделенного поставщика:", + deps.formatInventoryTraceRows(unresolvedRows, 12) + ); + } else if (summary.counterparties.length > 0) { + lines.push(`- В доступном закупочном следе встречаются поставщики: ${summary.counterparties.slice(0, 6).join("; ")}.`); + } + return buildFactualSummaryReply( + lines, + buildConfirmedBalanceSemantics(unresolvedRows.length > 0 ? "medium" : "strong", true) + ); + } const warehouseLabel = summary.warehouses[0] ?? "не указанного склада"; const directAnswerLine = summary.counterparties.length === 1 @@ -395,13 +487,30 @@ export function composeInventoryReply( const purchaseSummary = deps.summarizeInventoryTraceRows(purchaseRows); const saleSummary = deps.summarizeInventoryTraceRows(saleRows); const itemLabel = purchaseSummary.item ?? saleSummary.item ?? "товар не определен"; + const requestedSupplier = extractRequestedInventoryParty(options.userMessage, "supplier"); + const requestedBuyer = extractRequestedInventoryParty(options.userMessage, "buyer"); + const supplierMatches = inventoryRequestedPartyMatches(requestedSupplier, purchaseSummary.counterparties); + const buyerMatches = inventoryRequestedPartyMatches(requestedBuyer, saleSummary.counterparties); + const mismatchParts: string[] = []; + if (requestedSupplier && purchaseRows.length > 0 && !supplierMatches) { + mismatchParts.push( + `запрошенный поставщик ${requestedSupplier} не совпал с найденным поставщиком: ${inventoryPartyListOrUnknown(purchaseSummary.counterparties)}` + ); + } + if (requestedBuyer && saleRows.length > 0 && !buyerMatches) { + mismatchParts.push( + `запрошенный покупатель ${requestedBuyer} не совпал с найденным покупателем: ${inventoryPartyListOrUnknown(saleSummary.counterparties)}` + ); + } const directAnswerLine = - purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1 + mismatchParts.length > 0 + ? `Запрошенная цепочка по товару ${itemLabel} полностью не подтверждена: ${mismatchParts.join("; ")}.` + : purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1 ? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.` : `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`; const lines: string[] = [directAnswerLine, "", "Подтверждение:"]; - lines.push(`- Закупочных движений на 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`); - lines.push(`- Движений выбытия со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`); + lines.push(`- Строк закупки на 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`); + lines.push(`- Строк продажи со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`); if (purchaseRows.length > 0 && saleRows.length > 0) { lines.push("- В доступных данных найдены обе стороны цепочки: поступление и последующее выбытие."); } else if (purchaseRows.length > 0) { diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index 4e94851..ccd8adb 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -287,6 +287,36 @@ function readStateTransitionReasonCodes(input: ApplyAssistantMcpDiscoveryRespons .filter((item): item is string => Boolean(item)); } +function readStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.map((item) => toNonEmptyString(item)).filter((item): item is string => Boolean(item)) + : []; +} + +function hasExactMatchedFactualAddressReply( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status); + const truthMode = toNonEmptyString(input.addressRuntimeMeta?.truth_mode); + const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); + const bindingStatus = toNonEmptyString(input.addressRuntimeMeta?.capability_binding_status); + const bindingViolations = readStringArray(input.addressRuntimeMeta?.capability_binding_violations); + return Boolean( + mcpCallStatus === "matched_non_empty" && + truthMode === "confirmed" && + selectedRecipe?.startsWith("address_") && + (bindingStatus === "bound" || bindingStatus === "bound_with_limits") && + bindingViolations.length === 0 + ); +} + function hasRuntimeAdjustedExactReply( input: ApplyAssistantMcpDiscoveryResponsePolicyInput, entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null @@ -463,6 +493,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( const matchedFactualAddressContinuationTarget = hasMatchedFactualAddressContinuationTarget(input, entryPoint); const matchedFactualSuggestedIntentPivotTarget = hasMatchedFactualSuggestedIntentPivotTarget(input, entryPoint); const fullConfirmedFactualAddressReply = hasFullConfirmedFactualAddressReply(input, entryPoint); + const exactMatchedFactualAddressReply = hasExactMatchedFactualAddressReply(input, entryPoint); const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint); if (!entryPoint) { @@ -495,6 +526,9 @@ export function applyAssistantMcpDiscoveryResponsePolicy( if (fullConfirmedFactualAddressReply) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_full_confirmed_factual_address_reply"); } + if (exactMatchedFactualAddressReply) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_exact_matched_factual_address_reply"); + } if (runtimeAdjustedExactReply) { pushReason( reasonCodes, @@ -527,6 +561,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( !matchedFactualAddressContinuationTarget && !matchedFactualSuggestedIntentPivotTarget && !fullConfirmedFactualAddressReply && + !exactMatchedFactualAddressReply && !runtimeAdjustedExactReply && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && diff --git a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts index 6af6e28..85d6fa9 100644 --- a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts @@ -151,8 +151,17 @@ function resolveAddressLaneProtectionArbitration(input) { const semanticDeepInvestigationHintDetected = semanticGuardHints?.deep_investigation_signal_detected === true; const semanticAggregateShapeDetected = semanticExtraction?.query_shape === "AGGREGATE_LOOKUP" || semanticExtraction?.aggregation_profile === "management_profile"; + const exactSupportedIntentProtectedFromDeepPreference = Boolean(supportedAddressIntentDetected && + resolvedIntent && + ADDRESS_INTENTS_ALLOW_STRICT_DEEP_INVESTIGATION_BYPASS.has(resolvedIntent) && + semanticApplyCanonicalRecommended && + (!strictDeepInvestigationCueDetected || strictDeepInvestigationBypassAllowed)); + const unsupportedAggregateFollowupOverride = Boolean(followupContext && + llmContractMode === "unsupported" && + (semanticAggregateShapeDetected || !semanticApplyCanonicalRecommended) && + !exactSupportedIntentProtectedFromDeepPreference); const followupSemanticOverrideToDeepAllowed = Boolean(followupContext && - !supportedAddressIntentDetected && + (!supportedAddressIntentDetected || unsupportedAggregateFollowupOverride) && (rootContextOnlyFollowup || llmContractMode === "unsupported" || semanticAggregateShapeDetected || @@ -166,11 +175,16 @@ function resolveAddressLaneProtectionArbitration(input) { !deepAnalysisPreferenceDetected && !strictDeepInvestigationCueDetected && !semanticAggregateShapeDetected); + const unsupportedSpecificLlmIntent = Boolean(llmContractMode === "unsupported" && + llmContractIntent && + llmContractIntent !== "unknown"); const protectAddressLaneFromFallback = Boolean(supportedAddressRouteCandidateDetected && - !deepAnalysisPreferenceDetected && - (exactAddressIntentProtectedFromSemanticDeepHint || - !semanticDeepInvestigationHintDetected || - strictDeepInvestigationBypassAllowed)); + (exactSupportedIntentProtectedFromDeepPreference || + (!unsupportedSpecificLlmIntent && + !deepAnalysisPreferenceDetected && + (exactAddressIntentProtectedFromSemanticDeepHint || + !semanticDeepInvestigationHintDetected || + strictDeepInvestigationBypassAllowed)))); return { supportedAddressIntentDetected, supportedAddressRouteCandidateDetected, @@ -178,6 +192,7 @@ function resolveAddressLaneProtectionArbitration(input) { semanticAggregateShapeDetected, followupSemanticOverrideToDeepAllowed, exactAddressIntentProtectedFromSemanticDeepHint, + exactSupportedIntentProtectedFromDeepPreference, protectAddressLaneFromFallback }; } diff --git a/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts b/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts index 1cb6cc4..04cb195 100644 --- a/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts +++ b/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts @@ -309,7 +309,7 @@ export const INVENTORY_CAPABILITY_CONTRACTS: readonly AssistantCapabilityContrac entry_modes: ["root_entry", "root_followup", "clarification_resume"], transitions: ["T1", "T2", "T7"], requiresFocusObject: false, - requiredAnchors: ["supplier"], + requiredAnchors: [], resultShape: "supplier_to_stock_item_overlap", answerObjectShape: "inventory_supplier_overlap", bundleReusePolicy: "none", diff --git a/llm_normalizer/backend/tests/addressCoverageEvidencePolicy.test.ts b/llm_normalizer/backend/tests/addressCoverageEvidencePolicy.test.ts index cad9b40..a33450e 100644 --- a/llm_normalizer/backend/tests/addressCoverageEvidencePolicy.test.ts +++ b/llm_normalizer/backend/tests/addressCoverageEvidencePolicy.test.ts @@ -24,6 +24,31 @@ describe("address coverage evidence policy", () => { expect(contract.as_of_date_basis).toBe("explicit_as_of_date"); }); + it("keeps relative current snapshot basis even when it materializes to today's ISO date", () => { + const todayIso = new Date().toISOString().slice(0, 10); + const contract = resolveAddressCoverageEvidence({ + intent: "inventory_on_hand_as_of_date", + selectedRecipe: "address_inventory_on_hand_as_of_date_v1", + filters: { + as_of_date: todayIso + }, + semanticFrame: { + scope_kind: "implicit_self_scope", + anchor_kind: "self_scope", + anchor_value: null, + date_scope_kind: "implicit_current", + date_basis_hint: "implicit_current_snapshot", + self_scope_detected: true, + selected_object_scope_detected: false + }, + responseType: "FACTUAL_LIST", + rowsMatched: 1 + }); + + expect(contract.as_of_date_basis).toBe("implicit_current_snapshot"); + expect(contract.reason_codes).toContain("as_of_date_basis_implicit_current_snapshot"); + }); + it("treats factual exact negatives as full confirmed-balance evidence instead of partial noise", () => { const contract = resolveAddressCoverageEvidence({ intent: "payables_confirmed_as_of_date", diff --git a/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts b/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts index 4b72c28..fa3712e 100644 --- a/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts @@ -32,6 +32,13 @@ describe("addressInventoryIntentSignals", () => { expect(result.reasons).toContain("inventory_purchase_date_signal_detected"); }); + it("classifies direct Russian purchase-date wording with an explicit item", () => { + const result = resolveAddressIntent("Когда был куплен товар Столешница 600*3050*26 дуб ниагара"); + + expect(result.intent).toBe("inventory_purchase_provenance_for_item"); + expect(result.reasons).toContain("inventory_purchase_date_signal_detected"); + }); + it("does not steal non-inventory open-items wording into the inventory owner", () => { const result = resolveInventoryAddressIntent("хвосты покажи по счету 60 на август 2022"); diff --git a/llm_normalizer/backend/tests/addressInventoryPurchaseDateFollowup.test.ts b/llm_normalizer/backend/tests/addressInventoryPurchaseDateFollowup.test.ts index 3fbf299..2774a6b 100644 --- a/llm_normalizer/backend/tests/addressInventoryPurchaseDateFollowup.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryPurchaseDateFollowup.test.ts @@ -114,6 +114,52 @@ describe("inventory purchase-date selected-object follow-up", () => { expect(String(result?.reply_text ?? "")).not.toContain("Блок 1"); }); + it("routes direct canonical purchase-date wording with an explicit item through the exact provenance lane", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2019-02-12T00:00:00Z", + Registrator: "Поступление товаров и услуг 00000000003 от 12.02.2019 0:00:00", + AccountDt: "41.01", + AccountKt: "60.01", + Amount: 3724.17, + SubcontoDt1: "Столешница 600*3050*26 дуб ниагара", + SubcontoDt3: "Основной склад", + SubcontoKt1: "Торговый дом \\Союз МСК\\", + SubcontoKt2: "Договор поставки № 12 от 01.02.2019", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("Когда был куплен товар Столешница 600*3050*26 дуб ниагара", { + followupContext: { + previous_intent: "inventory_purchase_provenance_for_item", + previous_filters: { + item: "Столешница 600*3050*26 дуб ниагара", + warehouse: "Основной склад", + as_of_date: "2019-03-31" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + } + }); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("FACTUAL_SUMMARY"); + expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item"); + expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_provenance_for_item_v1"); + expect(result?.debug.extracted_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара"); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2019-03-31"); + expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("12.02.2019"); + expect(String(result?.reply_text ?? "")).not.toContain("Блок 1"); + }); + it("routes 'когда примерно мы купили' follow-up to compact purchase-date answer with the carried item", async () => { executeAddressMcpQueryMock.mockResolvedValueOnce({ fetched_rows: 1, diff --git a/llm_normalizer/backend/tests/addressInventorySaleTraceDocumentRoute.test.ts b/llm_normalizer/backend/tests/addressInventorySaleTraceDocumentRoute.test.ts index 71ec11d..f783295 100644 --- a/llm_normalizer/backend/tests/addressInventorySaleTraceDocumentRoute.test.ts +++ b/llm_normalizer/backend/tests/addressInventorySaleTraceDocumentRoute.test.ts @@ -87,14 +87,15 @@ describe("inventory sale trace movement route", () => { expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? ""); - expect(query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения"); - expect(query).toContain('ПОДСТРОКА(ЕСТЬNULL(Движения.СчетКт.Код, ""), 1, 5) = "41.01"'); - expect(query).toContain("Движения.СубконтоКт1 В (ВЫБРАТЬ Номенклатура.Ссылка"); + expect(query).toContain("Документ.РеализацияТоваровУслуг.Товары КАК Товары"); + expect(query).toContain('"41.01" КАК СчетКт'); + expect(query).toContain("Товары.Номенклатура В (ВЫБРАТЬ Номенклатура.Ссылка"); expect(query).toContain( 'Номенклатура.Наименование = "Рабочая станция универсального специалиста (индивидуальное изготовление)"' ); + expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент"); expect(query).not.toContain("2016-06-30"); expect(query).not.toContain("2016-06-01"); - expect(query).not.toContain('ПРЕДСТАВЛЕНИЕ(Движения.Организация) = "ООО \\Альтернатива Плюс\\"'); + expect(query).not.toContain('ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) = "ООО \\Альтернатива Плюс\\"'); }); }); diff --git a/llm_normalizer/backend/tests/addressInventorySaleTraceSelectedObjectRegression.test.ts b/llm_normalizer/backend/tests/addressInventorySaleTraceSelectedObjectRegression.test.ts index 0f79e88..4e22037 100644 --- a/llm_normalizer/backend/tests/addressInventorySaleTraceSelectedObjectRegression.test.ts +++ b/llm_normalizer/backend/tests/addressInventorySaleTraceSelectedObjectRegression.test.ts @@ -71,7 +71,9 @@ describe("inventory sale trace selected-object regressions", () => { expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? ""); - expect(query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения"); + expect(query).toContain("Документ.РеализацияТоваровУслуг.Товары КАК Товары"); + expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент"); + expect(query).toContain("Товары.Номенклатура В (ВЫБРАТЬ Номенклатура.Ссылка"); expect(query).toContain('Номенклатура.Наименование = "Кромка с клеем 33 дуб ниагара 137 м"'); expect(query).not.toContain('Номенклатура.Наименование = "Кромка"'); }); @@ -133,7 +135,9 @@ describe("inventory sale trace selected-object regressions", () => { expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? ""); - expect(query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения"); + expect(query).toContain("Документ.РеализацияТоваровУслуг.Товары КАК Товары"); + expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент"); + expect(query).toContain("Товары.Номенклатура В (ВЫБРАТЬ Номенклатура.Ссылка"); expect(query).toContain('Номенклатура.Наименование = "Кромка с клеем 33 дуб ниагара 137 м"'); expect(query).not.toContain('Номенклатура.Наименование = "Кромка"'); }); diff --git a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts index 5e0e2a5..491e65c 100644 --- a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts +++ b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts @@ -197,7 +197,8 @@ describe("inventory selected-object follow-up", () => { expect(result?.response_type).toBe("FACTUAL_SUMMARY"); expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item"); expect(result?.debug.extracted_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара"); - expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined(); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2019-03-31"); + expect(result?.debug.reasons).toContain("as_of_date_from_followup_context"); expect(String(result?.reply_text ?? "")).toContain("Торговый дом \\Союз МСК\\"); }); @@ -246,7 +247,8 @@ describe("inventory selected-object follow-up", () => { expect(result?.response_type).toBe("FACTUAL_SUMMARY"); expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item"); expect(result?.debug.extracted_filters?.item).toBe("Столешница 600*3050*26 альмандин"); - expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined(); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2021-03-31"); + expect(result?.debug.reasons).toContain("as_of_date_from_followup_context"); expect(result?.debug.capability_id).toBe("inventory_inventory_purchase_provenance_for_item"); expect(String(result?.reply_text ?? "")).toContain("Торговый дом \\Союз"); }); @@ -296,7 +298,8 @@ describe("inventory selected-object follow-up", () => { expect(result?.response_type).toBe("FACTUAL_SUMMARY"); expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item"); expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)"); - expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined(); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-07-31"); + expect(result?.debug.reasons).toContain("as_of_date_from_followup_context"); expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\"); }); @@ -346,9 +349,10 @@ describe("inventory selected-object follow-up", () => { expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item"); expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_provenance_for_item_v1"); expect(result?.debug.extracted_filters?.item).toBe("Конструкция трансформер рабочей станции 1300*900*2000"); - expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined(); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-06-30"); expect(result?.debug.extracted_filters?.period_from).toBeUndefined(); expect(result?.debug.extracted_filters?.period_to).toBeUndefined(); + expect(result?.debug.reasons).toContain("as_of_date_from_followup_context"); expect(result?.debug.capability_id).toBe("inventory_inventory_purchase_provenance_for_item"); expect(result?.debug.capability_route_mode).toBe("exact"); expect(String(result?.reply_text ?? "")).toContain("ООО \\Гамма-мебель\\"); @@ -399,7 +403,8 @@ describe("inventory selected-object follow-up", () => { expect(result?.response_type).toBe("FACTUAL_SUMMARY"); expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item"); expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)"); - expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined(); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-05-31"); + expect(result?.debug.reasons).toContain("as_of_date_from_followup_context"); expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\"); }); @@ -448,7 +453,8 @@ describe("inventory selected-object follow-up", () => { expect(result?.response_type).toBe("FACTUAL_SUMMARY"); expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item"); expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)"); - expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined(); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-05-31"); + expect(result?.debug.reasons).toContain("as_of_date_from_followup_context"); expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\"); }); @@ -498,7 +504,8 @@ describe("inventory selected-object follow-up", () => { expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item"); expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_provenance_for_item_v1"); expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)"); - expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined(); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-05-31"); + expect(result?.debug.reasons).toContain("as_of_date_from_followup_context"); expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\"); }); @@ -545,7 +552,8 @@ describe("inventory selected-object follow-up", () => { expect(result?.debug.detected_intent).toBe("inventory_purchase_documents_for_item"); expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_documents_for_item_v1"); expect(result?.debug.extracted_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара"); - expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined(); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2019-03-31"); + expect(result?.debug.reasons).toContain("as_of_date_from_followup_context"); expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000077"); }); @@ -646,7 +654,7 @@ describe("inventory selected-object follow-up", () => { expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item"); expect(result?.debug.selected_recipe).toBe("address_inventory_sale_trace_for_item_v1"); expect(result?.debug.extracted_filters?.item).toBe("Четки Пост (84*117)"); - expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined(); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31"); expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("ИП Покупатель"); expect(String(result?.reply_text ?? "")).toContain("Документы выбытия"); }); @@ -692,7 +700,7 @@ describe("inventory selected-object follow-up", () => { expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item"); expect(result?.debug.selected_recipe).toBe("address_inventory_sale_trace_for_item_v1"); expect(result?.debug.extracted_filters?.item).toBe("Пуф арий"); - expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined(); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-05-31"); expect(result?.debug.reasons).toContain("inventory_selected_object_sale_trace_signal_detected"); expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("ООО \\Ромашка\\"); expect(String(result?.reply_text ?? "")).toContain("Документы выбытия"); @@ -740,7 +748,7 @@ describe("inventory selected-object follow-up", () => { expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item"); expect(result?.debug.selected_recipe).toBe("address_inventory_sale_trace_for_item_v1"); expect(result?.debug.extracted_filters?.item).toBe("Кромка с клеем 33 альмандин 137 м"); - expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined(); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31"); expect(String(result?.reply_text ?? "")).toContain("ООО \\Покупатель\\"); }); @@ -787,7 +795,7 @@ describe("inventory selected-object follow-up", () => { expect(result?.handled).toBe(true); expect(result?.response_type).toBe("FACTUAL_LIST"); expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item"); - expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined(); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2019-03-31"); expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1); const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? ""); expect(query).not.toContain("2019-03-31"); @@ -826,6 +834,164 @@ describe("inventory selected-object follow-up", () => { expect(String(result?.reply_text ?? "")).not.toContain("совпадений не нашлось"); }); + it("keeps semantic stock period for unresolved supplier-link follow-up while querying purchase history up to as-of date", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2020-06-04T00:00:00Z", + Registrator: "Поступление товаров и услуг 00000000012 от 04.06.2020 13:36:29", + AccountDt: "41.01", + AccountKt: "60.01", + Amount: 439000, + SubcontoDt1: "Кресло для посетителей Экокожа/хром Цвет - оранжевый", + SubcontoDt3: "Основной склад", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("Какие товары сейчас висят в остатке без понятной привязки к поставщику", { + analysisDateHint: "2021-09-30", + followupContext: { + previous_intent: "inventory_aging_by_purchase_date", + previous_filters: { + as_of_date: "2021-09-30", + organization: "ООО \\Альтернатива Плюс\\" + }, + root_intent: "inventory_on_hand_as_of_date", + root_filters: { + period_from: "2021-09-01", + period_to: "2021-09-30", + as_of_date: "2021-09-30", + organization: "ООО \\Альтернатива Плюс\\" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + } + }); + + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("inventory_supplier_stock_overlap_as_of_date"); + expect(result?.debug.extracted_filters?.period_from).toBe("2021-09-01"); + expect(result?.debug.extracted_filters?.period_to).toBe("2021-09-30"); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2021-09-30"); + expect(result?.debug.reasons).toContain("period_window_semantic_from_inventory_snapshot_context"); + const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? ""); + expect(query).not.toContain("ДАТАВРЕМЯ(2021, 9, 1"); + expect(query).toContain("ДАТАВРЕМЯ(2021, 9, 30, 23, 59, 59)"); + }); + + it("derives semantic stock month for unresolved supplier-link when dialog continuation starts a new topic", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2020-06-04T00:00:00Z", + Registrator: "Purchase document 00000000012 from 2020-06-04", + AccountDt: "41.01", + AccountKt: "60.01", + Amount: 439000, + SubcontoDt1: "Office chair", + SubcontoDt3: "Main warehouse", + Organization: "OOO Test Org" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const unresolvedSupplierLinkQuestion = + "\u041a\u0430\u043a\u0438\u0435 \u0442\u043e\u0432\u0430\u0440\u044b \u0441\u0435\u0439\u0447\u0430\u0441 \u0432\u0438\u0441\u044f\u0442 \u0432 \u043e\u0441\u0442\u0430\u0442\u043a\u0435 \u0431\u0435\u0437 \u043f\u043e\u043d\u044f\u0442\u043d\u043e\u0439 \u043f\u0440\u0438\u0432\u044f\u0437\u043a\u0438 \u043a \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a\u0443"; + const result = await service.tryHandle(unresolvedSupplierLinkQuestion, { + analysisDateHint: "2021-09-30", + activeOrganization: "OOO Test Org" + }); + + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("inventory_supplier_stock_overlap_as_of_date"); + expect(result?.debug.extracted_filters?.period_from).toBe("2021-09-01"); + expect(result?.debug.extracted_filters?.period_to).toBe("2021-09-30"); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2021-09-30"); + expect(result?.debug.reasons).toContain("period_window_semantic_from_inventory_as_of_month"); + expect(result?.debug.reasons).not.toContain("period_window_semantic_from_inventory_snapshot_context"); + const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? ""); + expect(query).not.toContain("(2021, 9, 1"); + expect(query).toContain("(2021, 9, 30, 23, 59, 59)"); + }); + + it("treats supplier in purchase-to-sale chain as verification party, not stale organization scope", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 2, + matched_rows: 2, + raw_rows: [ + { + Период: "2019-12-10T00:00:00Z", + Регистратор: "Поступление товаров и услуг 00000000111 от 10.12.2019 12:00:01", + СчетДт: "41.01", + СчетКт: "", + Сумма: 855000, + Номенклатура: "Шкаф картотечный 1000*400*2100", + Контрагент: "ЭталонМебель", + Организация: "ООО \\Альтернатива Плюс\\", + Количество: 15 + }, + { + Период: "2020-04-01T00:00:00Z", + Регистратор: "Реализация товаров и услуг 00000000001 от 01.04.2020 0:00:00", + СчетДт: "", + СчетКт: "41.01", + Сумма: 855000, + Номенклатура: "Шкаф картотечный 1000*400*2100", + Контрагент: "ЭталонМебель", + Организация: "ООО \\Альтернатива Плюс\\", + Количество: 15 + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle( + "Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы", + { + activeOrganization: "ООО \\Альтернатива Плюс\\", + llmSemanticHints: { + scope_target_kind: "organization", + scope_target_text: "Гамма-мебель, ООО", + date_scope_kind: "explicit", + self_scope_detected: false, + selected_object_scope_detected: false + }, + followupContext: { + previous_intent: "inventory_on_hand_as_of_date", + previous_filters: { + as_of_date: "2020-03-31", + organization: "ООО \\Альтернатива Плюс\\" + }, + previous_anchor_type: "organization", + previous_anchor_value: "ООО \\Альтернатива Плюс\\" + } + } + ); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("FACTUAL_SUMMARY"); + expect(result?.debug.detected_intent).toBe("inventory_purchase_to_sale_chain"); + expect(result?.debug.extracted_filters?.organization).toBe("ООО Альтернатива Плюс"); + expect(result?.debug.reasons).toContain("organization_restored_from_inventory_chain_context"); + expect(result?.debug.reasons).toContain("inventory_chain_counterparty_anchor_kept_for_verification"); + expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("полностью не подтверждена"); + expect(String(result?.reply_text ?? "")).toContain("ЭталонМебель"); + }); + it.skip("keeps the full selected item when sale trace is asked in canonical wording after provenance", async () => { executeAddressMcpQueryMock.mockResolvedValueOnce({ fetched_rows: 1, @@ -914,9 +1080,10 @@ describe("inventory selected-object follow-up", () => { expect(result?.response_type).toBe("FACTUAL_SUMMARY"); expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item"); expect(result?.debug.extracted_filters?.item).toBe("Кресло орион"); - expect(result?.debug.extracted_filters?.as_of_date).toBeUndefined(); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31"); expect(result?.debug.extracted_filters?.period_from).toBeUndefined(); expect(result?.debug.extracted_filters?.period_to).toBeUndefined(); + expect(result?.debug.reasons).toContain("as_of_date_from_followup_context"); expect(result?.debug.reasons ?? []).not.toContain("lifecycle_execution_detached_from_snapshot_date"); expect(result?.debug.reasons ?? []).not.toContain("as_of_date_cleared_for_history_recovery"); expect(result?.debug.limitations ?? []).not.toContain("lifecycle_execution_detached_from_snapshot_date"); diff --git a/llm_normalizer/backend/tests/addressInventoryWarehouseAnchor.test.ts b/llm_normalizer/backend/tests/addressInventoryWarehouseAnchor.test.ts index f8f5f07..59aec92 100644 --- a/llm_normalizer/backend/tests/addressInventoryWarehouseAnchor.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryWarehouseAnchor.test.ts @@ -93,4 +93,26 @@ describe("inventory warehouse anchor extraction", () => { expect(filters.warehouse).toBeUndefined(); }); + + it("does not materialize current-moment canonical tail as warehouse anchor", () => { + const filters = extractAddressFilters( + "Какие товары находятся на складе в текущий момент", + "inventory_on_hand_as_of_date" + ).extracted_filters; + + expect(filters.warehouse).toBeUndefined(); + }); + + it("does not split organization-generic stock wording into stale warehouse and counterparty anchors", () => { + const filters = extractAddressFilters( + "Какие товары находились на складе компании в марте 2019 года?", + "inventory_on_hand_as_of_date" + ).extracted_filters; + + expect(filters.period_from).toBe("2019-03-01"); + expect(filters.period_to).toBe("2019-03-31"); + expect(filters.as_of_date).toBe("2019-03-31"); + expect(filters.warehouse).toBeUndefined(); + expect(filters.counterparty).toBeUndefined(); + }); }); diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index e45d622..2ad4ead 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -157,6 +157,17 @@ describe("address query shape classifier", () => { expect(filters.warehouse).toBeUndefined(); }); + it("extracts supplier anchor from supplier-item-buyer chain without swallowing the arrow tail", () => { + const extraction = extractAddressFilters( + "Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы", + "inventory_purchase_to_sale_chain" + ); + + expect(extraction.extracted_filters.counterparty).toBe("Гамма-мебель, ООО"); + expect(extraction.extracted_filters.item).toBe("Шкаф картотечный 1000*400*2100"); + expect(extraction.warnings).toContain("supplier_anchor_derived_for_inventory_documentary_chain"); + }); + it("cuts inventory item anchor before purchase-doc residue tail", () => { const filters = extractAddressFilters( "По каким документам был куплен товар Диван трехместный для остатка на складе Основной склад", @@ -317,6 +328,33 @@ describe("address query shape classifier", () => { expect(plan.query).toContain("41.01"); }); + it("builds sale-trace query from sale document rows with explicit buyer fields", () => { + const selected = selectAddressRecipe("inventory_sale_trace_for_item", { + item: "Шкаф картотечный" + }); + expect(selected.selected_recipe?.recipe_id).toBe("address_inventory_sale_trace_for_item_v1"); + const plan = buildAddressRecipePlan(selected.selected_recipe!, { + item: "Шкаф картотечный" + }); + expect(plan.query).toContain("Документ.РеализацияТоваровУслуг.Товары КАК Товары"); + expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент"); + expect(plan.query).toContain("Товары.Номенклатура В (ВЫБРАТЬ Номенклатура.Ссылка"); + }); + + it("builds purchase-to-sale chain query as purchase and sale document union", () => { + const selected = selectAddressRecipe("inventory_purchase_to_sale_chain", { + item: "Шкаф картотечный" + }); + expect(selected.selected_recipe?.recipe_id).toBe("address_inventory_purchase_to_sale_chain_v1"); + const plan = buildAddressRecipePlan(selected.selected_recipe!, { + item: "Шкаф картотечный" + }); + expect(plan.query).toContain("Документ.ПоступлениеТоваровУслуг.Товары КАК Товары"); + expect(plan.query).toContain("ОБЪЕДИНИТЬ ВСЕ"); + expect(plan.query).toContain("Документ.РеализацияТоваровУслуг.Товары КАК Товары"); + expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент"); + }); + it("renders inventory purchase documents from purchase-side 41.01 movements", () => { const reply = composeFactualReply( "inventory_purchase_documents_for_item", @@ -372,7 +410,7 @@ describe("address query shape classifier", () => { expect(reply.semantics?.balance_confirmed).toBe(true); }); - it("renders inventory sale trace from credit-side 41.01 movements", () => { + it("renders inventory sale trace from sale document rows", () => { const reply = composeFactualReply( "inventory_sale_trace_for_item", [ @@ -400,7 +438,7 @@ describe("address query shape classifier", () => { expect(reply.text).toContain("Департамент капитального ремонта города Москвы"); }); - it("renders purchase-to-sale chain from both sides of 41.01", () => { + it("renders purchase-to-sale chain from purchase and sale document rows", () => { const reply = composeFactualReply( "inventory_purchase_to_sale_chain", [ @@ -437,6 +475,45 @@ describe("address query shape classifier", () => { expect(reply.text).toContain("Реализация товаров и услуг 0007"); expect(reply.semantics?.result_mode).toBe("confirmed_balance"); }); + + it("states when requested supplier-to-buyer chain parties are not confirmed by item documents", () => { + const reply = composeFactualReply( + "inventory_purchase_to_sale_chain", + [ + { + period: "2019-12-10T00:00:00Z", + registrator: "Поступление товаров и услуг 00000000150 от 10.12.2019 0:00:00", + account_dt: "41.01", + account_kt: "", + amount: 712500, + analytics: ["Шкаф картотечный 1000*400*2100", "ЭталонМебель"], + item: "Шкаф картотечный 1000*400*2100", + counterparty: "ЭталонМебель", + organization: "ООО Альтернатива Плюс" + }, + { + period: "2020-04-01T00:00:00Z", + registrator: "Реализация товаров и услуг 00000000001 от 01.04.2020 0:00:00", + account_dt: "", + account_kt: "41.01", + amount: 855000, + analytics: ["Шкаф картотечный 1000*400*2100", "ЭталонМебель"], + item: "Шкаф картотечный 1000*400*2100", + counterparty: "ЭталонМебель", + organization: "ООО Альтернатива Плюс" + } + ], + { + userMessage: + "Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы?" + } + ); + + expect(reply.text.split("\n")[0]).toContain("полностью не подтверждена"); + expect(reply.text.split("\n")[0]).toContain("Гамма-мебель, ООО"); + expect(reply.text.split("\n")[0]).toContain("Департамент капитального ремонта города Москвы"); + expect(reply.text).toContain("ЭталонМебель"); + }); }); describe("address compose stage utf8 headers", () => { @@ -5377,6 +5454,35 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => { expect(reply.text).not.toContain("Контур:"); }); + it("answers unresolved supplier-link stock questions without asking for a supplier", () => { + 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", + userMessage: "Какие товары сейчас висят в остатке без понятной привязки к поставщику", + useRubCurrency: true + } + ); + + expect(reply.responseType).toBe("FACTUAL_SUMMARY"); + expect(reply.text.split("\n")[0]).toContain("без явно выделенного поставщика"); + expect(reply.text).toContain("Позиции без явно выделенного поставщика:"); + expect(reply.text).not.toContain("Уточните"); + }); + it("routes inventory provenance questions to a dedicated intent", () => { const result = resolveAddressIntent("От какого поставщика куплен товар Шкаф картоотечный?"); expect(result.intent).toBe("inventory_purchase_provenance_for_item"); @@ -5416,6 +5522,11 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => { expect(result.intent).toBe("inventory_sale_trace_for_item"); }); + it("routes passive buyer wording 'кому был продан товар' to inventory sale trace intent", () => { + const result = resolveAddressIntent("Кому был продан товар Шкаф картотечный 1000*400*2100?"); + expect(result.intent).toBe("inventory_sale_trace_for_item"); + }); + it("routes colloquial buyer wording with 'впарили' to inventory sale trace intent", () => { const result = resolveAddressIntent("Кому мы впарили этот товар Шкаф картотечный?"); expect(result.intent).toBe("inventory_sale_trace_for_item"); diff --git a/llm_normalizer/backend/tests/assistantCapabilityRuntimeBindingAdapter.test.ts b/llm_normalizer/backend/tests/assistantCapabilityRuntimeBindingAdapter.test.ts index 068c141..951e8f9 100644 --- a/llm_normalizer/backend/tests/assistantCapabilityRuntimeBindingAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantCapabilityRuntimeBindingAdapter.test.ts @@ -43,6 +43,31 @@ describe("assistant capability runtime binding adapter", () => { ); }); + it("allows supplier-overlap inventory aggregate questions without a supplier anchor", () => { + const binding = resolveAssistantCapabilityRuntimeBinding({ + addressDebug: { + capability_id: "inventory_inventory_supplier_stock_overlap_as_of_date", + detected_intent: "inventory_supplier_stock_overlap_as_of_date", + detected_mode: "address_query", + capability_layer: "compute", + capability_route_mode: "exact", + extracted_filters: { + warehouse: "Основной склад", + as_of_date: "2021-09-30" + }, + rows_matched: 500, + route_expectation_status: "matched" + }, + groundingStatus: "grounded", + replyType: "factual" + }); + + expect(binding.binding_status).toBe("bound"); + expect(binding.required_anchors).toEqual([]); + expect(binding.missing_anchors).toEqual([]); + expect(binding.violations).toEqual([]); + }); + it("binds selected-object follow-ups through item anchor when focus object is implicit", () => { const binding = resolveAssistantCapabilityRuntimeBinding({ addressDebug: { diff --git a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts index c68dad5..b1fc07a 100644 --- a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts @@ -1008,6 +1008,45 @@ describe("assistant orchestration contract", () => { expect(decision.orchestrationContract?.semantic_route_arbitration?.strict_deep_investigation_bypass_allowed).toBe(true); }); + it("keeps selected-object purchase-to-sale chain wording in address lane even when LLM predecompose calls it deep", () => { + const question = + 'По выбранному объекту "Шкаф картотечный 1000*400*2100": через какие документы прошел путь товара: закупка -> склад -> продажа'; + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: question, + effectiveAddressUserMessage: question, + followupContext: null, + llmPreDecomposeMeta: { + applied: true, + llmCanonicalCandidateDetected: true, + predecomposeContract: { + mode: "deep_analysis", + mode_confidence: "high", + intent: "list_documents_by_counterparty", + intent_confidence: "high" + }, + semanticExtractionContract: { + valid: true, + apply_canonical_recommended: true, + reason_codes: ["deep_investigation_signal_detected"], + guard_hints: { + deep_investigation_signal_detected: true + }, + extraction: { + query_shape: "DOCUMENT_LIST", + aggregation_profile: "list_lookup" + } + } + } as any, + useMock: false + }); + + expect(decision.runAddressLane).toBe(true); + expect(decision.toolGateDecision).toBe("run_address_lane"); + expect(decision.livingMode).toBe("address_data"); + expect(decision.orchestrationContract?.deep_analysis_signal_fallback_to_deep).toBe(false); + expect(decision.orchestrationContract?.address_intent).toBe("inventory_purchase_to_sale_chain"); + }); + it("keeps 'a na tekushuyu datu' follow-up in address lane when previous VAT context exists", () => { const decision = resolveAssistantOrchestrationDecision({ rawUserMessage: "а на текущую дату", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index d33bc97..a2a3ca0 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -135,6 +135,43 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_not_discovery_ready_address_candidate"); }); + it("keeps exact matched inventory address replies over stale metadata discovery candidates", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "По товару Шкаф картотечный 1000*400*2100 цепочка поставки и продажи подтверждена.", + 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({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "metadata", + asked_action_family: "inspect_documents", + metadata_scope_hint: "склад" + } + } + }) + } + }); + + expect(result.applied).toBe(false); + expect(result.decision).toBe("keep_current_reply"); + expect(result.reply_text).toContain("цепочка поставки и продажи подтверждена"); + 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("keeps aligned factual address lane answers when the exact lane already matched the same semantic intent", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "ИП Калинин Н.М. | сумма: 216600 | операций: 2",