diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 67e27e0..ae3a0fd 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -1372,6 +1372,8 @@ function extractInventorySupplierAnchor(text) { } const candidate = cleanupAnchorValue(cleanupAnchorValue(String(match[1])) .replace(/\s+(?:сейчас|на\s+склад(?:е|у|ом)?|на\s+дату|по\s+состоянию\s+на\s+дату|котор(?:ый|ые|ых)|куплен(?:ы|а|о)?|были|был|лежат|лежит|еще|ещё|организац\w*|компани\w*).*$/iu, "") + .replace(/\s+к\s+покупател\p{L}*[\s\S]*$/iu, "") + .replace(/\s+через\s+товар\p{L}*[\s\S]*$/iu, "") .replace(/\s*(?:->|=>|→)\s*(?:товар|позици|номенклатур|item|product|sku)[\s\S]*$/iu, "")); if (!candidate || isLowQualityCounterpartyAnchorValue(candidate) || diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index f9734e4..f59445d 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -1576,6 +1576,45 @@ function isQuestionFragmentPartyAnchor(value) { return /(?:^| )(?:что|кто|какие|какой|сколько|сейчас)(?: |$)/iu.test(normalized) || /(?:на складе|нам должен|кому мы должны|по этой компании|по этой же компании|по ней)/iu.test(normalized); } +function extractInventoryDocumentaryChainBuyerAnchor(value) { + const source = String(value ?? ""); + const patterns = [ + /(?:->|=>|→)\s*(?:покупател\p{L}*|buyer)\s+([^\r\n?]+?)(?=$|[?])/iu, + /(?:^|[\s,.;:!?])(?:к\s+покупател\p{L}*|покупател\p{L}*|buyer)\s+(.+?)(?=\s+через\s+(?:товар|item|product|sku)\b|$|[?])/iu + ]; + for (const pattern of patterns) { + const match = source.match(pattern); + const candidate = String(match?.[1] ?? "") + .replace(/\s*(?:->|=>|→).+$/u, "") + .replace(/\s+/g, " ") + .trim(); + if (candidate) { + return candidate; + } + } + return null; +} +function trimInventoryDocumentaryChainSupplierAnchor(value) { + const source = String(value ?? "").trim(); + if (!source) { + return null; + } + const candidate = source + .replace(/\s+к\s+покупател\p{L}*[\s\S]*$/iu, "") + .replace(/\s+через\s+товар\p{L}*[\s\S]*$/iu, "") + .replace(/\s*(?:->|=>|→)\s*(?:товар|позици|номенклатур|item|product|sku)[\s\S]*$/iu, "") + .replace(/\s+/g, " ") + .trim(); + return candidate && candidate !== source ? candidate : null; +} +function normalizeInventoryChainOrganizationFilterValue(value) { + const normalized = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(value); + const cleaned = String(normalized ?? "") + .replace(/[\\"]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return cleaned || null; +} function applyPreExecutionOrganizationScopeGrounding(input) { const activeOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(input.activeOrganization ?? null); const candidateOrganizations = (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)([ @@ -2988,14 +3027,65 @@ 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); + let chainCounterpartyAnchor = toNonEmptyFilterValue(filters.extracted_filters.counterparty); + let chainOrganizationAnchor = toNonEmptyFilterValue(filters.extracted_filters.organization); + if (intent.intent === "inventory_purchase_to_sale_chain") { + const trimmedChainCounterparty = trimInventoryDocumentaryChainSupplierAnchor(chainCounterpartyAnchor); + if (trimmedChainCounterparty) { + filters.extracted_filters.counterparty = trimmedChainCounterparty; + chainCounterpartyAnchor = trimmedChainCounterparty; + if (!filters.warnings.includes("inventory_chain_supplier_anchor_trimmed_for_verification")) { + filters.warnings.push("inventory_chain_supplier_anchor_trimmed_for_verification"); + } + if (!baseReasons.includes("inventory_chain_supplier_anchor_trimmed_for_verification")) { + baseReasons.push("inventory_chain_supplier_anchor_trimmed_for_verification"); + } + } + const buyerAnchor = extractInventoryDocumentaryChainBuyerAnchor(options.rawUserMessage ?? userMessage); + const organizationLooksLikeBuyer = Boolean(buyerAnchor) && + Boolean(chainOrganizationAnchor) && + sameOrganizationEntityReference(chainOrganizationAnchor, buyerAnchor); + const trustedOrganization = knownOrganizations.length === 1 + ? knownOrganizations[0] + : previousOrganizationFromContext && !sameOrganizationEntityReference(previousOrganizationFromContext, buyerAnchor) + ? previousOrganizationFromContext + : null; + if (trustedOrganization && + (!chainOrganizationAnchor || organizationLooksLikeBuyer) && + !sameOrganizationEntityReference(trustedOrganization, chainCounterpartyAnchor)) { + const restoredOrganizationForFilter = normalizeInventoryChainOrganizationFilterValue(trustedOrganization) ?? trustedOrganization; + filters.extracted_filters.organization = restoredOrganizationForFilter; + chainOrganizationAnchor = restoredOrganizationForFilter; + const reason = knownOrganizations.length === 1 + ? "organization_restored_from_known_scope_for_inventory_chain" + : "organization_restored_from_inventory_chain_context"; + if (!filters.warnings.includes(reason)) { + filters.warnings.push(reason); + } + if (!baseReasons.includes(reason)) { + baseReasons.push(reason); + } + } + } + if (intent.intent === "inventory_purchase_to_sale_chain") { + const normalizedChainOrganization = normalizeInventoryChainOrganizationFilterValue(filters.extracted_filters.organization); + if (normalizedChainOrganization && normalizedChainOrganization !== filters.extracted_filters.organization) { + filters.extracted_filters.organization = normalizedChainOrganization; + chainOrganizationAnchor = normalizedChainOrganization; + if (!filters.warnings.includes("organization_scope_normalized_for_inventory_chain")) { + filters.warnings.push("organization_scope_normalized_for_inventory_chain"); + } + if (!baseReasons.includes("organization_scope_normalized_for_inventory_chain")) { + baseReasons.push("organization_scope_normalized_for_inventory_chain"); + } + } + } if (intent.intent === "inventory_purchase_to_sale_chain" && chainCounterpartyAnchor && chainOrganizationAnchor && sameOrganizationEntityReference(chainOrganizationAnchor, chainCounterpartyAnchor)) { delete filters.extracted_filters.organization; - const restoredOrganization = activeOrganization ?? previousOrganizationFromContext; + const restoredOrganization = normalizeInventoryChainOrganizationFilterValue(activeOrganization ?? previousOrganizationFromContext); if (restoredOrganization && !sameOrganizationEntityReference(restoredOrganization, chainCounterpartyAnchor)) { filters.extracted_filters.organization = restoredOrganization; if (!filters.warnings.includes("organization_restored_from_inventory_chain_context")) { diff --git a/llm_normalizer/backend/dist/services/address_runtime/semanticHintOverlay.js b/llm_normalizer/backend/dist/services/address_runtime/semanticHintOverlay.js index bca231c..bd2a79b 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/semanticHintOverlay.js +++ b/llm_normalizer/backend/dist/services/address_runtime/semanticHintOverlay.js @@ -114,7 +114,10 @@ function applyAddressLlmSemanticHintsToExtraction(extraction, semanticHintsInput } semanticFrame.selected_object_scope_detected = true; } - if (semanticHints.scope_target_kind === "organization" && scopeTargetText) { + const protectsInventoryDocumentaryChainSupplierAnchor = warnings.includes("supplier_anchor_derived_for_inventory_documentary_chain"); + if (semanticHints.scope_target_kind === "organization" && + scopeTargetText && + !protectsInventoryDocumentaryChainSupplierAnchor) { extractedFilters.organization = scopeTargetText; pushWarning(warnings, "organization_from_llm_semantics"); if (toNonEmptyString(extractedFilters.warehouse)) { @@ -125,6 +128,9 @@ function applyAddressLlmSemanticHintsToExtraction(extraction, semanticHintsInput semanticFrame.anchor_kind = "organization"; semanticFrame.anchor_value = scopeTargetText; } + else if (semanticHints.scope_target_kind === "organization" && scopeTargetText) { + pushWarning(warnings, "organization_llm_semantics_ignored_for_inventory_documentary_chain"); + } if (semanticHints.scope_target_kind === "warehouse" && scopeTargetText) { extractedFilters.warehouse = scopeTargetText; pushWarning(warnings, "warehouse_from_llm_semantics"); diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index c81f64a..8d97b2d 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -1583,6 +1583,8 @@ function extractInventorySupplierAnchor(text: string): string | undefined { /\s+(?:сейчас|на\s+склад(?:е|у|ом)?|на\s+дату|по\s+состоянию\s+на\s+дату|котор(?:ый|ые|ых)|куплен(?:ы|а|о)?|были|был|лежат|лежит|еще|ещё|организац\w*|компани\w*).*$/iu, "" ) + .replace(/\s+к\s+покупател\p{L}*[\s\S]*$/iu, "") + .replace(/\s+через\s+товар\p{L}*[\s\S]*$/iu, "") .replace(/\s*(?:->|=>|→)\s*(?:товар|позици|номенклатур|item|product|sku)[\s\S]*$/iu, "") ); if ( diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index fb8f54a..74d6c25 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -1939,6 +1939,48 @@ function isQuestionFragmentPartyAnchor(value: string | null | undefined): boolea /(?:на складе|нам должен|кому мы должны|по этой компании|по этой же компании|по ней)/iu.test(normalized); } +function extractInventoryDocumentaryChainBuyerAnchor(value: string | null | undefined): string | null { + const source = String(value ?? ""); + const patterns = [ + /(?:->|=>|→)\s*(?:покупател\p{L}*|buyer)\s+([^\r\n?]+?)(?=$|[?])/iu, + /(?:^|[\s,.;:!?])(?:к\s+покупател\p{L}*|покупател\p{L}*|buyer)\s+(.+?)(?=\s+через\s+(?:товар|item|product|sku)\b|$|[?])/iu + ]; + for (const pattern of patterns) { + const match = source.match(pattern); + const candidate = String(match?.[1] ?? "") + .replace(/\s*(?:->|=>|→).+$/u, "") + .replace(/\s+/g, " ") + .trim(); + if (candidate) { + return candidate; + } + } + return null; +} + +function trimInventoryDocumentaryChainSupplierAnchor(value: string | null | undefined): string | null { + const source = String(value ?? "").trim(); + if (!source) { + return null; + } + const candidate = source + .replace(/\s+к\s+покупател\p{L}*[\s\S]*$/iu, "") + .replace(/\s+через\s+товар\p{L}*[\s\S]*$/iu, "") + .replace(/\s*(?:->|=>|→)\s*(?:товар|позици|номенклатур|item|product|sku)[\s\S]*$/iu, "") + .replace(/\s+/g, " ") + .trim(); + return candidate && candidate !== source ? candidate : null; +} + +function normalizeInventoryChainOrganizationFilterValue(value: string | null | undefined): string | null { + const normalized = normalizeOrganizationScopeValue(value); + const cleaned = String(normalized ?? "") + .replace(/[\\"]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return cleaned || null; +} + function applyPreExecutionOrganizationScopeGrounding(input: { userMessage: string; filters: AddressFilterSet; @@ -3716,8 +3758,66 @@ 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); + let chainCounterpartyAnchor = toNonEmptyFilterValue(filters.extracted_filters.counterparty); + let chainOrganizationAnchor = toNonEmptyFilterValue(filters.extracted_filters.organization); + if (intent.intent === "inventory_purchase_to_sale_chain") { + const trimmedChainCounterparty = trimInventoryDocumentaryChainSupplierAnchor(chainCounterpartyAnchor); + if (trimmedChainCounterparty) { + filters.extracted_filters.counterparty = trimmedChainCounterparty; + chainCounterpartyAnchor = trimmedChainCounterparty; + if (!filters.warnings.includes("inventory_chain_supplier_anchor_trimmed_for_verification")) { + filters.warnings.push("inventory_chain_supplier_anchor_trimmed_for_verification"); + } + if (!baseReasons.includes("inventory_chain_supplier_anchor_trimmed_for_verification")) { + baseReasons.push("inventory_chain_supplier_anchor_trimmed_for_verification"); + } + } + const buyerAnchor = extractInventoryDocumentaryChainBuyerAnchor(options.rawUserMessage ?? userMessage); + const organizationLooksLikeBuyer = + Boolean(buyerAnchor) && + Boolean(chainOrganizationAnchor) && + sameOrganizationEntityReference(chainOrganizationAnchor, buyerAnchor); + const trustedOrganization = + knownOrganizations.length === 1 + ? knownOrganizations[0] + : previousOrganizationFromContext && !sameOrganizationEntityReference(previousOrganizationFromContext, buyerAnchor) + ? previousOrganizationFromContext + : null; + if ( + trustedOrganization && + (!chainOrganizationAnchor || organizationLooksLikeBuyer) && + !sameOrganizationEntityReference(trustedOrganization, chainCounterpartyAnchor) + ) { + const restoredOrganizationForFilter = + normalizeInventoryChainOrganizationFilterValue(trustedOrganization) ?? trustedOrganization; + filters.extracted_filters.organization = restoredOrganizationForFilter; + chainOrganizationAnchor = restoredOrganizationForFilter; + const reason = knownOrganizations.length === 1 + ? "organization_restored_from_known_scope_for_inventory_chain" + : "organization_restored_from_inventory_chain_context"; + if (!filters.warnings.includes(reason)) { + filters.warnings.push(reason); + } + if (!baseReasons.includes(reason)) { + baseReasons.push(reason); + } + } + } + if (intent.intent === "inventory_purchase_to_sale_chain") { + const normalizedChainOrganization = normalizeInventoryChainOrganizationFilterValue( + filters.extracted_filters.organization + ); + if (normalizedChainOrganization && normalizedChainOrganization !== filters.extracted_filters.organization) { + filters.extracted_filters.organization = normalizedChainOrganization; + chainOrganizationAnchor = normalizedChainOrganization; + if (!filters.warnings.includes("organization_scope_normalized_for_inventory_chain")) { + filters.warnings.push("organization_scope_normalized_for_inventory_chain"); + } + if (!baseReasons.includes("organization_scope_normalized_for_inventory_chain")) { + baseReasons.push("organization_scope_normalized_for_inventory_chain"); + } + } + } if ( intent.intent === "inventory_purchase_to_sale_chain" && chainCounterpartyAnchor && @@ -3725,7 +3825,9 @@ export class AddressQueryService { sameOrganizationEntityReference(chainOrganizationAnchor, chainCounterpartyAnchor) ) { delete filters.extracted_filters.organization; - const restoredOrganization = activeOrganization ?? previousOrganizationFromContext; + const restoredOrganization = normalizeInventoryChainOrganizationFilterValue( + activeOrganization ?? previousOrganizationFromContext + ); if (restoredOrganization && !sameOrganizationEntityReference(restoredOrganization, chainCounterpartyAnchor)) { filters.extracted_filters.organization = restoredOrganization; if (!filters.warnings.includes("organization_restored_from_inventory_chain_context")) { diff --git a/llm_normalizer/backend/src/services/address_runtime/semanticHintOverlay.ts b/llm_normalizer/backend/src/services/address_runtime/semanticHintOverlay.ts index 6837b0b..c74508a 100644 --- a/llm_normalizer/backend/src/services/address_runtime/semanticHintOverlay.ts +++ b/llm_normalizer/backend/src/services/address_runtime/semanticHintOverlay.ts @@ -145,7 +145,15 @@ export function applyAddressLlmSemanticHintsToExtraction( semanticFrame.selected_object_scope_detected = true; } - if (semanticHints.scope_target_kind === "organization" && scopeTargetText) { + const protectsInventoryDocumentaryChainSupplierAnchor = warnings.includes( + "supplier_anchor_derived_for_inventory_documentary_chain" + ); + + if ( + semanticHints.scope_target_kind === "organization" && + scopeTargetText && + !protectsInventoryDocumentaryChainSupplierAnchor + ) { extractedFilters.organization = scopeTargetText; pushWarning(warnings, "organization_from_llm_semantics"); if (toNonEmptyString(extractedFilters.warehouse)) { @@ -155,6 +163,8 @@ export function applyAddressLlmSemanticHintsToExtraction( semanticFrame.scope_kind = "explicit_anchor"; semanticFrame.anchor_kind = "organization"; semanticFrame.anchor_value = scopeTargetText; + } else if (semanticHints.scope_target_kind === "organization" && scopeTargetText) { + pushWarning(warnings, "organization_llm_semantics_ignored_for_inventory_documentary_chain"); } if (semanticHints.scope_target_kind === "warehouse" && scopeTargetText) { diff --git a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts index 8d68842..ec59c73 100644 --- a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts +++ b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts @@ -1147,12 +1147,84 @@ describe("inventory selected-object follow-up", () => { 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("organization_scope_normalized_for_inventory_chain"); 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("restores known organization when live chain semantics misclassify buyer as active organization", 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: "Департамент капитального ремонта города Москвы", + knownOrganizations: ["ООО \"Альтернатива Плюс\""], + analysisDateHint: "2020-03-31", + llmSemanticHints: { + scope_target_kind: "organization", + scope_target_text: "Департамент капитального ремонта города Москвы", + date_scope_kind: "missing", + self_scope_detected: false, + selected_object_scope_detected: false + }, + followupContext: { + previous_intent: "inventory_purchase_to_sale_chain", + previous_filters: { + as_of_date: "2020-03-31", + item: "Шкаф картотечный 1000*400*2100", + organization: "ООО \"Альтернатива Плюс\"" + }, + previous_anchor_type: "item", + previous_anchor_value: "Шкаф картотечный 1000*400*2100" + } + } + ); + + 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?.counterparty).toBe("Гамма-мебель, ООО"); + expect(result?.debug.extracted_filters?.organization).toBe("ООО Альтернатива Плюс"); + expect(result?.debug.reasons).toContain("organization_scope_normalized_for_inventory_chain"); + 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 ?? "").split("\n")[0]).toContain("Гамма-мебель, ООО"); + 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, diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 685fbdf..cfbe8b2 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -186,6 +186,35 @@ describe("address query shape classifier", () => { expect(extraction.warnings).toContain("supplier_anchor_derived_for_inventory_documentary_chain"); }); + it("extracts supplier anchor from normalized supplier-buyer chain without swallowing buyer and item 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("ignores buyer-as-organization llm semantics for supplier-item-buyer inventory chain", () => { + const result = runAddressDecomposeStage( + "Проверить наличие документально подтвержденной цепочки поставки от поставщика Гамма-мебель, ООО к покупателю Департамент капитального ремонта города Москвы через товар Шкаф картотечный 1000*400*2100", + null, + { + scope_target_kind: "organization", + scope_target_text: "Департамент капитального ремонта города Москвы", + date_scope_kind: "missing", + self_scope_detected: false, + selected_object_scope_detected: false + } + ); + + expect(result?.filters.extracted_filters.counterparty).toBe("Гамма-мебель, ООО"); + expect(result?.filters.extracted_filters.organization).toBeUndefined(); + expect(result?.filters.warnings).toContain("organization_llm_semantics_ignored_for_inventory_documentary_chain"); + }); + it("cuts inventory item anchor before purchase-doc residue tail", () => { const filters = extractAddressFilters( "По каким документам был куплен товар Диван трехместный для остатка на складе Основной склад",