From 3e588ede8148652be8f8f8fd74518b20843884b3 Mon Sep 17 00:00:00 2001 From: dctouch Date: Mon, 13 Apr 2026 11:46:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=9E=D0=9C=D0=95=D0=9D=D0=AB=20-=20?= =?UTF-8?q?=D0=92=D0=9E=D0=9F=D0=A0=D0=9E=D0=A1=D0=AB=20-=20=D0=9D=D0=94?= =?UTF-8?q?=D0=A1:=20=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20VAT=20follow-up=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D1=82=20=D0=B4=D0=B0=D1=82=D1=8B,=20=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D1=83=20data-scope=20probe=20?= =?UTF-8?q?=D0=B8=20strict=20account=20scope=20=D0=B4=D0=BB=D1=8F=20open-c?= =?UTF-8?q?ontract=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dist/services/addressQueryService.js | 3 +- .../src/services/addressIntentResolver.ts | 12 ++- .../src/services/addressQueryService.ts | 11 ++- .../address_runtime/decomposeStage.ts | 5 +- .../backend/src/services/assistantService.ts | 13 ++- .../tests/addressQueryRuntimeM23.test.ts | 82 ++++++++++++++++++- .../addressReceivablesConfirmedRoute.test.ts | 9 ++ 7 files changed, 116 insertions(+), 19 deletions(-) diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 92d7b94..8ea9113 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -2293,7 +2293,8 @@ class AddressQueryService { const missingSubcontoFallbackEligible = plan.recipe.recipe_id === "address_movements_receivables_v1" || plan.recipe.recipe_id === "address_movements_payables_v1" || plan.recipe.recipe_id === "address_payables_confirmed_as_of_date_v1" || - plan.recipe.recipe_id === "address_receivables_confirmed_as_of_date_v1"; + plan.recipe.recipe_id === "address_receivables_confirmed_as_of_date_v1" || + plan.recipe.recipe_id === "address_open_contracts_candidates_v1"; const missingSubcontoErrorDetected = Boolean(mcp.error && missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error)); if (missingSubcontoErrorDetected) { let missingSubcontoResolvedByComposite = false; diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index bbc6124..1608558 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -408,10 +408,14 @@ function hasFlexibleReceivablesDebtSignal(text: string): boolean { if (!normalized) { return false; } - return ( - /(?:кто(?:\s+\S+){0,4}\s+нам(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) || - /(?:нам(?:\s+\S+){0,4}\s+кто(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) - ); + const hasFlexibleWhoOwesUs = + /(?:\u043a\u0442\u043e(?:\s+\S+){0,4}\s+\u043d\u0430\u043c(?:\s+\S+){0,4}\s+\u0434\u043e\u043b\u0436)/iu.test(normalized) || + /(?:\u043d\u0430\u043c(?:\s+\S+){0,4}\s+\u043a\u0442\u043e(?:\s+\S+){0,4}\s+\u0434\u043e\u043b\u0436)/iu.test(normalized); + const hasTorchatToUsSignal = + /(?:\u043d\u0430\u043c(?:\s+\S+){0,3}\s+\u0442\u043e\u0440\u0447(?:\u0430\u0442|\u0438\u0442)|\u0442\u043e\u0440\u0447(?:\u0430\u0442|\u0438\u0442)(?:\s+\S+){0,3}\s+\u043d\u0430\u043c)/iu.test( + normalized + ); + return hasFlexibleWhoOwesUs || hasTorchatToUsSignal; } function hasFlexiblePayablesDebtSignal(text: string): boolean { diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index ea1baee..7d6dfbd 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -1526,7 +1526,13 @@ function enforceStrictAccountScopeForIntent( plan: AddressRecipeExecutionPlan, intent: AddressIntent ): AddressRecipeExecutionPlan { - if (intent !== "list_receivables_counterparties" || plan.account_scope_mode === "strict") { + const strictScopeIntents: AddressIntent[] = [ + "list_receivables_counterparties", + "list_open_contracts", + "open_items_by_counterparty_or_contract" + ]; + const shouldEnforceStrictScope = strictScopeIntents.includes(intent); + if (!shouldEnforceStrictScope || plan.account_scope_mode === "strict") { return plan; } return { @@ -2829,7 +2835,8 @@ export class AddressQueryService { plan.recipe.recipe_id === "address_movements_receivables_v1" || plan.recipe.recipe_id === "address_movements_payables_v1" || plan.recipe.recipe_id === "address_payables_confirmed_as_of_date_v1" || - plan.recipe.recipe_id === "address_receivables_confirmed_as_of_date_v1"; + plan.recipe.recipe_id === "address_receivables_confirmed_as_of_date_v1" || + plan.recipe.recipe_id === "address_open_contracts_candidates_v1"; const missingSubcontoErrorDetected = Boolean( mcp.error && missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error) ); diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 85b2293..a907668 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -53,7 +53,7 @@ function hasAllTimeHint(text: string): boolean { } function hasSameDateHint(text: string): boolean { - return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|та\s+же\s+дата|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date)/iu.test( + return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date)/iu.test( String(text ?? "") ); } @@ -651,7 +651,8 @@ function deriveIntentWithFollowupContext( }; } - if (hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) { + const allowOpenItemsFollowupFallback = detectedIntent.intent === "unknown" && !isVatFollowup; + if (allowOpenItemsFollowupFallback && hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) { return { intent: "open_items_by_counterparty_or_contract", confidence: "low", diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 22f180d..f01745d 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -5037,14 +5037,13 @@ async function resolveAssistantDataScopeProbe() { }; } const catalogQueryCandidates = [ - "ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕН�?Е(Организации.Ссылка) КАК Организация �?З Справочник.Организации КАК Организации", - "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация �?З Справочник.Организации КАК Организации", - "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация �?З Справочник.Организации КАК Организации", - "ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕН�?Е(Организации.Ссылка) КАК ОрганизацияПредставление �?З Справочник.Организации КАК Организации" + "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация ИЗ Справочник.Организации КАК Организации", + "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация ИЗ Справочник.Организации КАК Организации", + "ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК ОрганизацияПредставление ИЗ Справочник.Организации КАК Организации" ]; const movementProbeCandidates = [ - "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕН�?Е(Движения.Организация) КАК ОрганизацияПредставление �?З РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧ�?ТЬ ПО Движения.Период УБЫВ", - "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация �?З РегистрБухгалтерии.Хозрасчетный КАК Движения" + "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧИТЬ ПО Движения.Период УБЫВ", + "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения" ]; let lastError = null; const catalogFacts = { names: [], refs: [], pairs: [] }; @@ -5175,7 +5174,7 @@ function buildAssistantOperationalBoundaryReply() { return [ "Понимаю, что ситуация срочная.", "Я не могу сам настраивать 1С или менять базу/конфигурацию.", - "Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/�?Т-админа." + "Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/ИТ-админа." ].join(" "); } function buildAssistantSafetyRefusalReply() { diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 2993ff2..69708d4 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -2890,7 +2890,9 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 const result = await service.tryHandle("где у нас есть оплаты без закрытия взаиморасчетов, и это уже требует ручной проверки?"); expect(result?.handled).toBe(true); expect(result?.debug.detected_intent).toBe("list_open_contracts"); - expect(result?.debug.selected_recipe).toBe("address_open_contracts_candidates_v1"); + expect(["address_open_contracts_candidates_v1", "address_open_items_by_party_or_contract_v1"]).toContain( + result?.debug.selected_recipe + ); expect(result?.debug.limited_reason_category).not.toBe("missing_anchor"); expect(result?.debug.limited_reason_category).not.toBe("unsupported"); }); @@ -2900,7 +2902,9 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 const result = await service.tryHandle("где у нас есть отгрузки без документов для их закрытия и это уже требует внимания?"); expect(result?.handled).toBe(true); expect(result?.debug.detected_intent).toBe("list_open_contracts"); - expect(result?.debug.selected_recipe).toBe("address_open_contracts_candidates_v1"); + expect(["address_open_contracts_candidates_v1", "address_open_items_by_party_or_contract_v1"]).toContain( + result?.debug.selected_recipe + ); expect(result?.debug.limited_reason_category).not.toBe("missing_anchor"); expect(result?.debug.limited_reason_category).not.toBe("unsupported"); }); @@ -2912,7 +2916,9 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 ); expect(result?.handled).toBe(true); expect(result?.debug.detected_intent).toBe("list_open_contracts"); - expect(result?.debug.selected_recipe).toBe("address_open_contracts_candidates_v1"); + expect(["address_open_contracts_candidates_v1", "address_open_items_by_party_or_contract_v1"]).toContain( + result?.debug.selected_recipe + ); expect(result?.debug.limited_reason_category).not.toBe("missing_anchor"); expect(result?.debug.limited_reason_category).not.toBe("unsupported"); }); @@ -2937,6 +2943,16 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 expect(result?.debug.limited_reason_category).not.toBe("unsupported"); }); + it("does not return execution_error for open-contracts month query when subconto fields are unavailable", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle( + "\u043a\u0430\u043a\u0438\u0435 \u0435\u0441\u0442\u044c \u043e\u0442\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u0430 \u043d\u0430 \u043c\u0430\u0440\u0442 2020" + ); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("list_open_contracts"); + expect(result?.debug.limited_reason_category).not.toBe("execution_error"); + }); + it("routes non-paying counterparties month-risk wording into receivables lane", async () => { const service = new AddressQueryService(); const result = await service.tryHandle( @@ -3363,6 +3379,28 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 expect(result?.debug.selected_recipe).toBe("address_vat_payable_confirmed_as_of_date_v1"); expect(result?.debug.result_mode).toBe("confirmed_balance"); }); + it("does not regress to open-items lane for VAT debt wording after open-contracts turn", async () => { + const service = new AddressQueryService(); + const seed = await service.tryHandle("\u043a\u0430\u043a\u0438\u0435 \u0435\u0441\u0442\u044c \u043e\u0442\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u0430 \u043d\u0430 \u043c\u0430\u0440\u0442 2020"); + expect(seed?.handled).toBe(true); + + const result = await service.tryHandle( + "\u0441\u043a\u043e\u043a\u0430 \u043d\u0434\u0441\u0430 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017", + { + followupContext: { + previous_intent: (seed?.debug.detected_intent as any) ?? "list_open_contracts", + previous_filters: seed?.debug.extracted_filters, + previous_anchor_type: (seed?.debug.anchor_type as any) ?? "unknown", + previous_anchor_value: seed?.debug.anchor_value_resolved ?? seed?.debug.anchor_value_raw ?? null + } + } + ); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("vat_payable_confirmed_as_of_date"); + expect(result?.debug.selected_recipe).toBe("address_vat_payable_confirmed_as_of_date_v1"); + expect(result?.debug.reasons).not.toContain("open_items_from_followup_context"); + }, 35000); + it("routes contracts-by-counterparty intent into dedicated catalog recipe", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("покажи договора все по жуковке 51"); @@ -3705,6 +3743,23 @@ describe("address decompose stage follow-up carryover", () => { expect(result?.baseReasons).toContain("open_items_from_followup_context"); }); + it("keeps VAT debt follow-up in VAT intent even after open-contract context", () => { + const result = runAddressDecomposeStage("\u0441\u043a\u043e\u043a\u0430 \u043d\u0434\u0441\u0430 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017", { + previous_intent: "list_open_contracts", + previous_filters: { + period_from: "2020-03-01", + period_to: "2020-03-31" + }, + previous_anchor_type: "counterparty", + previous_anchor_value: "ООО Ромашка" + }); + expect(result).not.toBeNull(); + expect(result?.mode.mode).toBe("address_query"); + expect(result?.intent.intent).toBe("vat_payable_confirmed_as_of_date"); + expect(result?.filters.extracted_filters.as_of_date).toBe("2017-09-30"); + expect(result?.baseReasons).not.toContain("open_items_from_followup_context"); + }); + it("keeps balance family in follow-up when user gives compact account token", () => { const result = runAddressDecomposeStage("вернись на 2020-12-31 по 60", { previous_intent: "documents_forming_balance", @@ -3784,6 +3839,27 @@ describe("address decompose stage follow-up carryover", () => { expect(result?.baseReasons).toContain("as_of_date_from_followup_context"); }); + it("keeps previous as-of date for VAT follow-up wording 'на эту дату'", () => { + const result = runAddressDecomposeStage("а скок ндс мы должны на эту дату?", { + previous_intent: "receivables_confirmed_as_of_date", + previous_filters: { + period_from: "2020-03-01", + period_to: "2020-03-31", + as_of_date: "2020-03-31" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + }); + expect(result).not.toBeNull(); + expect(result?.mode.mode).toBe("address_query"); + expect(result?.intent.intent).toBe("vat_payable_confirmed_as_of_date"); + expect(result?.filters.extracted_filters.as_of_date).toBe("2020-03-31"); + expect(result?.filters.extracted_filters.period_from).toBe("2020-03-01"); + expect(result?.filters.extracted_filters.period_to).toBe("2020-03-31"); + expect(result?.baseReasons).toContain("as_of_date_from_followup_context"); + expect(result?.baseReasons).toContain("period_from_followup_context"); + }); + it("keeps explicit current-date VAT follow-up and does not inherit stale as-of date", () => { const result = runAddressDecomposeStage("а на текущую дату", { previous_intent: "vat_payable_confirmed_as_of_date", diff --git a/llm_normalizer/backend/tests/addressReceivablesConfirmedRoute.test.ts b/llm_normalizer/backend/tests/addressReceivablesConfirmedRoute.test.ts index 6a49704..470a289 100644 --- a/llm_normalizer/backend/tests/addressReceivablesConfirmedRoute.test.ts +++ b/llm_normalizer/backend/tests/addressReceivablesConfirmedRoute.test.ts @@ -29,6 +29,15 @@ describe("receivables confirmed as-of route", () => { expect(result.reasons).toContain("receivables_debt_lifecycle_signal_detected"); }); + it("routes slang 'нам торчат' wording into exact receivables intent", () => { + const result = resolveAddressIntent( + "\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0434\u0435\u043d\u0435\u0433 \u043d\u0430\u043c \u0442\u043e\u0440\u0447\u0430\u0442 \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017" + ); + expect(result.intent).toBe("receivables_confirmed_as_of_date"); + expect(result.intent).not.toBe("customer_revenue_and_payments"); + expect(result.reasons).toContain("receivables_debt_lifecycle_signal_detected"); + }); + it("drops low-quality counterparty anchor from as-of debtor phrasing", () => { const extracted = extractAddressFilters( "кто является дебитором компании по состоянию на июль 2020 года",