From bba4717dbe88d6304b3cc29dabe5081ed9f841c4 Mon Sep 17 00:00:00 2001 From: dctouch Date: Fri, 24 Apr 2026 15:21:03 +0300 Subject: [PATCH] =?UTF-8?q?Post-F:=20=D0=B7=D0=B0=D0=BA=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20open-scope=20=D0=B8=20bidirectional=20valu?= =?UTF-8?q?e-flow=20integrity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dist/services/addressIntentResolver.js | 14 ++++++ .../dist/services/addressQueryService.js | 37 ++++++++++++++ .../src/services/addressIntentResolver.ts | 33 +++++++++++++ .../src/services/addressQueryService.ts | 49 +++++++++++++++++++ ...tentResolverBidirectionalValueFlow.test.ts | 13 +++++ .../tests/addressQueryRuntimeM23.test.ts | 19 +++++++ 6 files changed, 165 insertions(+) create mode 100644 llm_normalizer/backend/tests/addressIntentResolverBidirectionalValueFlow.test.ts diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 7e7b11c..911bd3b 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1554,6 +1554,17 @@ function repairLikelyUtf8Mojibake(text) { function unicodeBridgeResolution(intent, confidence, reason) { return { intent, confidence, reasons: [reason] }; } +function hasBidirectionalValueFlowComparisonSignal(text) { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) { + return false; + } + const hasIncomingCue = /(?:\u0432\u0445\u043e\u0434\u044f\u0449|\u043f\u043e\u0441\u0442\u0443\u043f|\u043f\u043e\u043b\u0443\u0447|inflow|incoming)/iu.test(normalized); + const hasOutgoingCue = /(?:\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u0441\u043f\u0438\u0441\u0430\u043d|\u0437\u0430\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442\u0438\u043b|\u043e\u043f\u043b\u0430\u0442|outflow|outgoing|payout)/iu.test(normalized); + const hasComparisonCue = /(?:\u0431\u043e\u043b\u044c\u0448|\u043c\u0435\u043d\u044c\u0448|\u0441\u0440\u0430\u0432|\u0438\u043b\u0438|\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|vs|versus)/iu.test(normalized); + const hasValueFlowCue = /(?:\u0434\u0435\u043d\u044c\u0433|\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u0435\u0436|\u043f\u043e\u0442\u043e\u043a|\u043e\u0431\u043e\u0440\u043e\u0442|money|cash|flow)/iu.test(normalized); + return hasIncomingCue && hasOutgoingCue && hasComparisonCue && hasValueFlowCue; +} function resolveUnicodeAddressIntentBridge(text) { const normalized = String(text ?? "").trim().toLowerCase(); if (!normalized) { @@ -1592,6 +1603,9 @@ function resolveUnicodeAddressIntentBridge(text) { ]).has(byAnchorToken); const hasMoneyCue = /(?:деньг|денег|выручк|доход|оборот|заработ|прин[её]с|чек|ликвидн|revenue|turnover|money)/iu.test(normalized); const hasRankingCue = /(?:топ|ранк|сам(?:ый|ая|ое|ые)|больше\s+всего|наибольш|крупн|жирн|max|top|rank)/iu.test(normalized); + if (hasBidirectionalValueFlowComparisonSignal(normalized)) { + return unicodeBridgeResolution("unknown", "high", "unicode_bidirectional_value_flow_deferred_to_discovery"); + } if (/(?:за\s+какие\s+годы|диапазон\s+лет|покрыт(?:ие|ый)\s+период|какой\s+год.*актив|какой\s+месяц.*актив|год.*пассив|месяц.*пассив|минимальн.*док|минимальн.*операц|месяц[\s-]*пик|profile\s+period|top\s*year|top\s*month)/iu.test(normalized)) { return unicodeBridgeResolution("period_coverage_profile", "high", "unicode_period_coverage_bridge_signal_detected"); } diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 91a67bb..c01a10e 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -9,6 +9,7 @@ const resolveStage_1 = require("./address_runtime/resolveStage"); const composeStage_1 = require("./address_runtime/composeStage"); const addressCapabilityPolicy_1 = require("./addressCapabilityPolicy"); const addressRouteExpectations_1 = require("./addressRouteExpectations"); +const addressTextRepair_1 = require("./addressTextRepair"); const assistantOrganizationMatcher_1 = require("./assistantOrganizationMatcher"); const addressCoverageEvidencePolicy_1 = require("./addressCoverageEvidencePolicy"); const addressTruthGatePolicy_1 = require("./addressTruthGatePolicy"); @@ -1617,6 +1618,21 @@ function isOrganizationScopedInventoryIntent(intent) { intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date"); } +function isOrganizationScopedValueFlowIntent(intent) { + return intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile"; +} +function hasExplicitSingleOrganizationValueFlowScopeRequest(userMessage) { + const raw = String(userMessage ?? "").toLowerCase(); + const repaired = (0, addressTextRepair_1.repairAddressMojibakeText)(raw).toLowerCase(); + const normalized = `${raw} ${repaired}`; + const hasOrganizationCue = /(?:организац|компани|фирм|контур|organization|company)/iu.test(normalized); + if (!hasOrganizationCue) { + return false; + } + const hasSingleOrganizationCue = /(?:по\s+одн(?:ой|у)\s+(?:организац|компани|фирм)|одн(?:ой|у)\s+(?:организац|компани|фирм)|one\s+(?:organization|company))/iu.test(normalized); + const hasClarificationCue = /(?:если[^.?!]*(?:нужн|надо|потреб)[^.?!]*(?:организац|компани|фирм)|(?:уточн|спрос)[^.?!]*(?:организац|компани|фирм|ее|её))/iu.test(normalized); + return hasSingleOrganizationCue || hasClarificationCue; +} function shouldDeferInventoryOrganizationClarification(intent, filters, semanticFrame) { if (!isOrganizationScopedInventoryIntent(intent)) { return false; @@ -2804,6 +2820,27 @@ class AddressQueryService { }); const knownOrganizations = (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)(options.knownOrganizations ?? []); const activeOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.activeOrganization ?? null); + if (isOrganizationScopedValueFlowIntent(intent.intent) && + hasExplicitSingleOrganizationValueFlowScopeRequest(userMessage) && + !resolvedOrganizationFromMessage) { + const clarificationFilters = { ...filters.extracted_filters }; + delete clarificationFilters.organization; + return buildOrganizationClarificationExecutionResult({ + mode, + shape, + intent, + filters: clarificationFilters, + organizations: knownOrganizations, + reasons: [...baseReasons, "organization_clarification_required_from_explicit_value_flow_scope"], + semanticFrame, + capabilityAudit: buildCapabilityAudit(intent.intent), + shadowRouteAudit: buildShadowRouteAudit({ + intent: intent.intent, + requestedResultMode: (0, addressCoverageEvidencePolicy_1.resolveAddressRequestedResultMode)(intent.intent, filters.extracted_filters, semanticFrame) ?? undefined, + filters: filters.extracted_filters + }) + }); + } if (isOrganizationScopedInventoryIntent(intent.intent) && !toNonEmptyFilterValue(filters.extracted_filters.organization) && !activeOrganization && diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index b2a9f20..43d12f5 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -1954,6 +1954,31 @@ function unicodeBridgeResolution( return { intent, confidence, reasons: [reason] }; } +function hasBidirectionalValueFlowComparisonSignal(text: string): boolean { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) { + return false; + } + + const hasIncomingCue = /(?:\u0432\u0445\u043e\u0434\u044f\u0449|\u043f\u043e\u0441\u0442\u0443\u043f|\u043f\u043e\u043b\u0443\u0447|inflow|incoming)/iu.test( + normalized + ); + const hasOutgoingCue = + /(?:\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u0441\u043f\u0438\u0441\u0430\u043d|\u0437\u0430\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442\u0438\u043b|\u043e\u043f\u043b\u0430\u0442|outflow|outgoing|payout)/iu.test( + normalized + ); + const hasComparisonCue = + /(?:\u0431\u043e\u043b\u044c\u0448|\u043c\u0435\u043d\u044c\u0448|\u0441\u0440\u0430\u0432|\u0438\u043b\u0438|\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|vs|versus)/iu.test( + normalized + ); + const hasValueFlowCue = + /(?:\u0434\u0435\u043d\u044c\u0433|\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u0435\u0436|\u043f\u043e\u0442\u043e\u043a|\u043e\u0431\u043e\u0440\u043e\u0442|money|cash|flow)/iu.test( + normalized + ); + + return hasIncomingCue && hasOutgoingCue && hasComparisonCue && hasValueFlowCue; +} + function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolution | null { const normalized = String(text ?? "").trim().toLowerCase(); if (!normalized) { @@ -2006,6 +2031,14 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio normalized ); + if (hasBidirectionalValueFlowComparisonSignal(normalized)) { + return unicodeBridgeResolution( + "unknown", + "high", + "unicode_bidirectional_value_flow_deferred_to_discovery" + ); + } + if ( /(?:за\s+какие\s+годы|диапазон\s+лет|покрыт(?:ие|ый)\s+период|какой\s+год.*актив|какой\s+месяц.*актив|год.*пассив|месяц.*пассив|минимальн.*док|минимальн.*операц|месяц[\s-]*пик|profile\s+period|top\s*year|top\s*month)/iu.test( normalized diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index feb8629..0969fcc 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -56,6 +56,7 @@ import { resolveShadowRouteIntent } from "./addressCapabilityPolicy"; import { evaluateAddressRouteExpectation, type AddressRouteExpectationAudit } from "./addressRouteExpectations"; +import { repairAddressMojibakeText } from "./addressTextRepair"; import { mergeKnownOrganizations, normalizeOrganizationScopeSearchText, @@ -1994,6 +1995,31 @@ function isOrganizationScopedInventoryIntent(intent: AddressIntent): boolean { ); } +function isOrganizationScopedValueFlowIntent(intent: AddressIntent): boolean { + return intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile"; +} + +function hasExplicitSingleOrganizationValueFlowScopeRequest(userMessage: string): boolean { + const raw = String(userMessage ?? "").toLowerCase(); + const repaired = repairAddressMojibakeText(raw).toLowerCase(); + const normalized = `${raw} ${repaired}`; + const hasOrganizationCue = /(?:организац|компани|фирм|контур|organization|company)/iu.test(normalized); + if (!hasOrganizationCue) { + return false; + } + + const hasSingleOrganizationCue = + /(?:по\s+одн(?:ой|у)\s+(?:организац|компани|фирм)|одн(?:ой|у)\s+(?:организац|компани|фирм)|one\s+(?:organization|company))/iu.test( + normalized + ); + const hasClarificationCue = + /(?:если[^.?!]*(?:нужн|надо|потреб)[^.?!]*(?:организац|компани|фирм)|(?:уточн|спрос)[^.?!]*(?:организац|компани|фирм|ее|её))/iu.test( + normalized + ); + + return hasSingleOrganizationCue || hasClarificationCue; +} + function shouldDeferInventoryOrganizationClarification( intent: AddressIntent, filters: AddressFilterSet, @@ -3480,6 +3506,29 @@ export class AddressQueryService { }); const knownOrganizations = mergeKnownOrganizations(options.knownOrganizations ?? []); const activeOrganization = normalizeOrganizationScopeValue(options.activeOrganization ?? null); + if ( + isOrganizationScopedValueFlowIntent(intent.intent) && + hasExplicitSingleOrganizationValueFlowScopeRequest(userMessage) && + !resolvedOrganizationFromMessage + ) { + const clarificationFilters: AddressFilterSet = { ...filters.extracted_filters }; + delete clarificationFilters.organization; + return buildOrganizationClarificationExecutionResult({ + mode, + shape, + intent, + filters: clarificationFilters, + organizations: knownOrganizations, + reasons: [...baseReasons, "organization_clarification_required_from_explicit_value_flow_scope"], + semanticFrame, + capabilityAudit: buildCapabilityAudit(intent.intent), + shadowRouteAudit: buildShadowRouteAudit({ + intent: intent.intent, + requestedResultMode: resolveAddressRequestedResultMode(intent.intent, filters.extracted_filters, semanticFrame) ?? undefined, + filters: filters.extracted_filters + }) + }); + } if ( isOrganizationScopedInventoryIntent(intent.intent) && !toNonEmptyFilterValue(filters.extracted_filters.organization) && diff --git a/llm_normalizer/backend/tests/addressIntentResolverBidirectionalValueFlow.test.ts b/llm_normalizer/backend/tests/addressIntentResolverBidirectionalValueFlow.test.ts new file mode 100644 index 0000000..3db15cd --- /dev/null +++ b/llm_normalizer/backend/tests/addressIntentResolverBidirectionalValueFlow.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { resolveAddressIntent } from "../src/services/addressIntentResolver"; + +describe("address intent resolver bidirectional value-flow arbitration", () => { + it("keeps incoming-vs-outgoing comparison out of one-sided payout routes", () => { + const result = resolveAddressIntent( + "\u0447\u0442\u043e \u043f\u043e \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441 \u0431\u043e\u043b\u044c\u0448\u0435 \u0432 2020 \u0433\u043e\u0434\u0443: \u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0438\u043b\u0438 \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0434\u0435\u043d\u044c\u0433\u0438?" + ); + + expect(result.intent).toBe("unknown"); + expect(result.reasons).toContain("unicode_bidirectional_value_flow_deferred_to_discovery"); + }); +}); diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 22d922f..72b8201 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -4734,6 +4734,25 @@ describe("address decompose stage follow-up carryover", () => { expect(result?.baseReasons).not.toContain("open_items_from_followup_context"); }); + it("asks for organization when an open value-flow total explicitly requests one-organization scope", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle( + "\u0421 \u0416\u0443\u043a\u043e\u0432\u043a\u043e\u0439 \u0437\u0430\u043a\u043e\u043d\u0447\u0438\u043b\u0438. \u0422\u0435\u043f\u0435\u0440\u044c \u043d\u0443\u0436\u043d\u0430 \u0434\u0440\u0443\u0433\u0430\u044f \u0437\u0430\u0434\u0430\u0447\u0430: \u0431\u044b\u0441\u0442\u0440\u044b\u0439 \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0439 \u0441\u0440\u0435\u0437 \u043f\u043e \u043e\u0434\u043d\u043e\u0439 \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438. \u0415\u0441\u043b\u0438 \u0434\u043b\u044f \u043e\u0442\u0432\u0435\u0442\u0430 \u043d\u0443\u0436\u043d\u0430 \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044f, \u043f\u0440\u043e\u0441\u0442\u043e \u0443\u0442\u043e\u0447\u043d\u0438 \u0435\u0435. \u0421\u043a\u043e\u043b\u044c\u043a\u043e \u0432\u043e\u043e\u0431\u0449\u0435 \u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445 \u0434\u0435\u043d\u0435\u0433 \u0431\u044b\u043b\u043e \u0437\u0430 2020 \u0433\u043e\u0434?", + { + knownOrganizations: [ + "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441", + "\u041e\u041e\u041e \u0420\u043e\u043c\u0430\u0448\u043a\u0430" + ] + } + ); + + expect(result?.reply_type).toBe("partial_coverage"); + expect(result?.debug.missing_required_filters).toContain("organization"); + expect(result?.debug.mcp_call_status).toBe("skipped"); + expect(result?.debug.reasons).toContain("organization_clarification_required_from_explicit_value_flow_scope"); + expect(result?.reply_text).toMatch(/организац/iu); + }); + 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",