Починить проверку цепочки поставщик товар покупатель

This commit is contained in:
dctouch 2026-06-03 13:35:31 +03:00
parent 584edb7e5b
commit 325fcf50d1
8 changed files with 322 additions and 9 deletions

View File

@ -1372,6 +1372,8 @@ function extractInventorySupplierAnchor(text) {
} }
const candidate = cleanupAnchorValue(cleanupAnchorValue(String(match[1])) const candidate = cleanupAnchorValue(cleanupAnchorValue(String(match[1]))
.replace(/\s+(?:сейчас|на\s+склад(?:е|у|ом)?|на\s+дату|по\s+состоянию\s+на\s+дату|котор(?:ый|ые|ых)|куплен(?:ы|а|о)?|были|был|лежат|лежит|еще|ещё|организац\w*|компани\w*).*$/iu, "") .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, "")); .replace(/\s*(?:->|=>|→)\s*(?:товар|позици|номенклатур|item|product|sku)[\s\S]*$/iu, ""));
if (!candidate || if (!candidate ||
isLowQualityCounterpartyAnchorValue(candidate) || isLowQualityCounterpartyAnchorValue(candidate) ||

View File

@ -1576,6 +1576,45 @@ function isQuestionFragmentPartyAnchor(value) {
return /(?:^| )(?:что|кто|какие|какой|сколько|сейчас)(?: |$)/iu.test(normalized) || return /(?:^| )(?:что|кто|какие|какой|сколько|сейчас)(?: |$)/iu.test(normalized) ||
/(?:на складе|нам должен|кому мы должны|по этой компании|по этой же компании|по ней)/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) { function applyPreExecutionOrganizationScopeGrounding(input) {
const activeOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(input.activeOrganization ?? null); const activeOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(input.activeOrganization ?? null);
const candidateOrganizations = (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)([ const candidateOrganizations = (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)([
@ -2988,14 +3027,65 @@ class AddressQueryService {
const knownOrganizations = (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)(options.knownOrganizations ?? []); const knownOrganizations = (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)(options.knownOrganizations ?? []);
const activeOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.activeOrganization ?? null); const activeOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.activeOrganization ?? null);
const previousOrganizationFromContext = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(followupContext?.previous_filters?.organization ?? null); const previousOrganizationFromContext = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(followupContext?.previous_filters?.organization ?? null);
const chainCounterpartyAnchor = toNonEmptyFilterValue(filters.extracted_filters.counterparty); let chainCounterpartyAnchor = toNonEmptyFilterValue(filters.extracted_filters.counterparty);
const chainOrganizationAnchor = toNonEmptyFilterValue(filters.extracted_filters.organization); 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" && if (intent.intent === "inventory_purchase_to_sale_chain" &&
chainCounterpartyAnchor && chainCounterpartyAnchor &&
chainOrganizationAnchor && chainOrganizationAnchor &&
sameOrganizationEntityReference(chainOrganizationAnchor, chainCounterpartyAnchor)) { sameOrganizationEntityReference(chainOrganizationAnchor, chainCounterpartyAnchor)) {
delete filters.extracted_filters.organization; delete filters.extracted_filters.organization;
const restoredOrganization = activeOrganization ?? previousOrganizationFromContext; const restoredOrganization = normalizeInventoryChainOrganizationFilterValue(activeOrganization ?? previousOrganizationFromContext);
if (restoredOrganization && !sameOrganizationEntityReference(restoredOrganization, chainCounterpartyAnchor)) { if (restoredOrganization && !sameOrganizationEntityReference(restoredOrganization, chainCounterpartyAnchor)) {
filters.extracted_filters.organization = restoredOrganization; filters.extracted_filters.organization = restoredOrganization;
if (!filters.warnings.includes("organization_restored_from_inventory_chain_context")) { if (!filters.warnings.includes("organization_restored_from_inventory_chain_context")) {

View File

@ -114,7 +114,10 @@ function applyAddressLlmSemanticHintsToExtraction(extraction, semanticHintsInput
} }
semanticFrame.selected_object_scope_detected = true; 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; extractedFilters.organization = scopeTargetText;
pushWarning(warnings, "organization_from_llm_semantics"); pushWarning(warnings, "organization_from_llm_semantics");
if (toNonEmptyString(extractedFilters.warehouse)) { if (toNonEmptyString(extractedFilters.warehouse)) {
@ -125,6 +128,9 @@ function applyAddressLlmSemanticHintsToExtraction(extraction, semanticHintsInput
semanticFrame.anchor_kind = "organization"; semanticFrame.anchor_kind = "organization";
semanticFrame.anchor_value = scopeTargetText; 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) { if (semanticHints.scope_target_kind === "warehouse" && scopeTargetText) {
extractedFilters.warehouse = scopeTargetText; extractedFilters.warehouse = scopeTargetText;
pushWarning(warnings, "warehouse_from_llm_semantics"); pushWarning(warnings, "warehouse_from_llm_semantics");

View File

@ -1583,6 +1583,8 @@ function extractInventorySupplierAnchor(text: string): string | undefined {
/\s+(?:сейчас|на\s+склад(?:е|у|ом)?|на\s+дату|по\s+состоянию\s+на\s+дату|котор(?:ый|ые|ых)|куплен(?:ы|а|о)?|были|был|лежат|лежит|еще|ещё|организац\w*|компани\w*).*$/iu, /\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, "") .replace(/\s*(?:->|=>|)\s*(?:товар|позици|номенклатур|item|product|sku)[\s\S]*$/iu, "")
); );
if ( if (

View File

@ -1939,6 +1939,48 @@ function isQuestionFragmentPartyAnchor(value: string | null | undefined): boolea
/(?:на складе|нам должен|кому мы должны|по этой компании|по этой же компании|по ней)/iu.test(normalized); /(?:на складе|нам должен|кому мы должны|по этой компании|по этой же компании|по ней)/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: { function applyPreExecutionOrganizationScopeGrounding(input: {
userMessage: string; userMessage: string;
filters: AddressFilterSet; filters: AddressFilterSet;
@ -3716,8 +3758,66 @@ export class AddressQueryService {
const knownOrganizations = mergeKnownOrganizations(options.knownOrganizations ?? []); const knownOrganizations = mergeKnownOrganizations(options.knownOrganizations ?? []);
const activeOrganization = normalizeOrganizationScopeValue(options.activeOrganization ?? null); const activeOrganization = normalizeOrganizationScopeValue(options.activeOrganization ?? null);
const previousOrganizationFromContext = normalizeOrganizationScopeValue(followupContext?.previous_filters?.organization ?? null); const previousOrganizationFromContext = normalizeOrganizationScopeValue(followupContext?.previous_filters?.organization ?? null);
const chainCounterpartyAnchor = toNonEmptyFilterValue(filters.extracted_filters.counterparty); let chainCounterpartyAnchor = toNonEmptyFilterValue(filters.extracted_filters.counterparty);
const chainOrganizationAnchor = toNonEmptyFilterValue(filters.extracted_filters.organization); 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 ( if (
intent.intent === "inventory_purchase_to_sale_chain" && intent.intent === "inventory_purchase_to_sale_chain" &&
chainCounterpartyAnchor && chainCounterpartyAnchor &&
@ -3725,7 +3825,9 @@ export class AddressQueryService {
sameOrganizationEntityReference(chainOrganizationAnchor, chainCounterpartyAnchor) sameOrganizationEntityReference(chainOrganizationAnchor, chainCounterpartyAnchor)
) { ) {
delete filters.extracted_filters.organization; delete filters.extracted_filters.organization;
const restoredOrganization = activeOrganization ?? previousOrganizationFromContext; const restoredOrganization = normalizeInventoryChainOrganizationFilterValue(
activeOrganization ?? previousOrganizationFromContext
);
if (restoredOrganization && !sameOrganizationEntityReference(restoredOrganization, chainCounterpartyAnchor)) { if (restoredOrganization && !sameOrganizationEntityReference(restoredOrganization, chainCounterpartyAnchor)) {
filters.extracted_filters.organization = restoredOrganization; filters.extracted_filters.organization = restoredOrganization;
if (!filters.warnings.includes("organization_restored_from_inventory_chain_context")) { if (!filters.warnings.includes("organization_restored_from_inventory_chain_context")) {

View File

@ -145,7 +145,15 @@ export function applyAddressLlmSemanticHintsToExtraction(
semanticFrame.selected_object_scope_detected = true; 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; extractedFilters.organization = scopeTargetText;
pushWarning(warnings, "organization_from_llm_semantics"); pushWarning(warnings, "organization_from_llm_semantics");
if (toNonEmptyString(extractedFilters.warehouse)) { if (toNonEmptyString(extractedFilters.warehouse)) {
@ -155,6 +163,8 @@ export function applyAddressLlmSemanticHintsToExtraction(
semanticFrame.scope_kind = "explicit_anchor"; semanticFrame.scope_kind = "explicit_anchor";
semanticFrame.anchor_kind = "organization"; semanticFrame.anchor_kind = "organization";
semanticFrame.anchor_value = scopeTargetText; 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) { if (semanticHints.scope_target_kind === "warehouse" && scopeTargetText) {

View File

@ -1147,12 +1147,84 @@ describe("inventory selected-object follow-up", () => {
expect(result?.response_type).toBe("FACTUAL_SUMMARY"); expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_purchase_to_sale_chain"); expect(result?.debug.detected_intent).toBe("inventory_purchase_to_sale_chain");
expect(result?.debug.extracted_filters?.organization).toBe("ООО Альтернатива Плюс"); 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(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 ?? "")).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 () => { it.skip("keeps the full selected item when sale trace is asked in canonical wording after provenance", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({ executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1, fetched_rows: 1,

View File

@ -186,6 +186,35 @@ describe("address query shape classifier", () => {
expect(extraction.warnings).toContain("supplier_anchor_derived_for_inventory_documentary_chain"); 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", () => { it("cuts inventory item anchor before purchase-doc residue tail", () => {
const filters = extractAddressFilters( const filters = extractAddressFilters(
"По каким документам был куплен товар Диван трехместный для остатка на складе Основной склад", "По каким документам был куплен товар Диван трехместный для остатка на складе Основной склад",