From bbb3620956f85aa05bb0c2b76c109cb05a066185 Mon Sep 17 00:00:00 2001 From: dctouch Date: Mon, 15 Jun 2026 18:57:11 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D1=87=D0=B8=D1=81=D1=82=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BF=D0=B0=D0=BC=D1=8F=D1=82=D1=8C=20=D0=B0=D1=81?= =?UTF-8?q?=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BD=D1=82=D0=B0=20=D0=BE=D1=82?= =?UTF-8?q?=20=D1=80=D0=BE=D0=BB=D0=B5=D0=B2=D1=8B=D1=85=20=D1=84=D1=80?= =?UTF-8?q?=D0=B0=D0=B3=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=80=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dist/services/addressNavigationState.js | 36 ++++++++++- .../services/assistantContinuityPolicy.js | 19 +++++- .../src/services/addressNavigationState.ts | 48 +++++++++++++- .../src/services/assistantContinuityPolicy.ts | 20 +++++- .../tests/addressNavigationState.test.ts | 62 +++++++++++++++++++ .../tests/assistantContinuityPolicy.test.ts | 27 ++++++++ 6 files changed, 204 insertions(+), 8 deletions(-) diff --git a/llm_normalizer/backend/dist/services/addressNavigationState.js b/llm_normalizer/backend/dist/services/addressNavigationState.js index 852aba9..e4df264 100644 --- a/llm_normalizer/backend/dist/services/addressNavigationState.js +++ b/llm_normalizer/backend/dist/services/addressNavigationState.js @@ -5,6 +5,7 @@ exports.cloneAddressNavigationState = cloneAddressNavigationState; exports.normalizeAddressNavigationState = normalizeAddressNavigationState; exports.evolveAddressNavigationStateWithAssistantItem = evolveAddressNavigationStateWithAssistantItem; const nanoid_1 = require("nanoid"); +const addressTextRepair_1 = require("./addressTextRepair"); const assistantContinuityPolicy_1 = require("./assistantContinuityPolicy"); const addressNavigation_1 = require("../types/addressNavigation"); const MAX_RESULT_SETS = 40; @@ -295,6 +296,32 @@ function normalizeFilters(value) { } return { ...record }; } +function isBusinessOverviewRoleCounterpartyFragment(value) { + const text = (0, addressTextRepair_1.normalizeRussianComparableText)(value); + if (!text) { + return false; + } + const hasRoleWord = /(?:^|\s)(?:клиент|поставщик|контрагент|получатель|источник|покупатель|заказчик)(?:\s|$)/u.test(text); + if (!hasRoleWord) { + return false; + } + const hasRankingWord = /(?:^|\s)(?:главн[а-яёa-z0-9_-]*|крупнейш[а-яёa-z0-9_-]*|основн[а-яёa-z0-9_-]*|топ(?:-\d+)?)(?:\s|$)/u.test(text); + const hasRoleOnlyExplanation = /(?:^|\s)(?:клиент|поставщик)(?:\s|$).{0,40}(?:^|\s)как(?:\s|$).{0,30}(?:^|\s)рол/u.test(text); + const endsWithDanglingPreposition = /(?:^|\s)(?:в|за|по|на|от|для|к)$/u.test(text); + return hasRoleOnlyExplanation || (hasRankingWord && (endsWithDanglingPreposition || text.length <= 48)); +} +function shouldSuppressBusinessOverviewCounterpartyFocus(debug, counterparty) { + return (toNonEmptyString(debug.mcp_discovery_selected_chain_id) === "business_overview" && + debug.mcp_discovery_response_applied === true && + isBusinessOverviewRoleCounterpartyFragment(counterparty)); +} +function sanitizeBusinessOverviewNavigationFilters(filters, debug) { + if (!shouldSuppressBusinessOverviewCounterpartyFocus(debug, filters.counterparty)) { + return filters; + } + const { counterparty: _counterparty, ...rest } = filters; + return rest; +} function resolveNavigationAction(debug, hasFocusObject) { const continuationContract = toObject(debug.dialog_continuation_contract_v2); const decision = toNonEmptyString(continuationContract?.decision); @@ -361,7 +388,7 @@ function buildFocusObjectFromDebug(debug, resultSetId, createdAt) { const selectedDiscoveryChain = toNonEmptyString(debug.mcp_discovery_selected_chain_id); if (selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true) { const counterparty = (0, assistantContinuityPolicy_1.readAddressDebugCounterparty)(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug); - if (counterparty) { + if (counterparty && !shouldSuppressBusinessOverviewCounterpartyFocus(debug, counterparty)) { return buildFocusObject("counterparty", counterparty, resultSetId, createdAt); } const organization = (0, assistantContinuityPolicy_1.readAddressDebugOrganization)(debug, toNonEmptyString); @@ -574,7 +601,7 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) { const createdAt = toNonEmptyString(item.created_at) ?? new Date().toISOString(); const resultSetId = `rs-${item.message_id}`; const routeId = toNonEmptyString(debug.selected_recipe) ?? selectedDiscoveryChain; - const filters = normalizeFilters(debug.extracted_filters); + const filters = sanitizeBusinessOverviewNavigationFilters(normalizeFilters(debug.extracted_filters), debug); const derivedOrganizationScope = resolveDerivedOrganizationScope(debug, filters, item.text) ?? (0, assistantContinuityPolicy_1.readAddressDebugOrganization)(debug, toNonEmptyString); const derivedCounterpartyScope = selectedDiscoveryChain === "value_flow_comparison" && debug.mcp_discovery_response_applied === true ? (0, assistantContinuityPolicy_1.readAddressDebugCounterparty)(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug) @@ -610,9 +637,12 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) { const debugFocusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt); const primaryEntityFocusObject = buildFocusObjectFromPrimaryEntityRef(resultSet, createdAt); const focusObject = resolveFocusObjectForNavigation(state, intent, resultSet, debugFocusObject, primaryEntityFocusObject); - const comparisonCounterparty = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true + const rawComparisonCounterparty = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true ? readNavigationDiscoveryCounterparty(debug) : null; + const comparisonCounterparty = rawComparisonCounterparty && !shouldSuppressBusinessOverviewCounterpartyFocus(debug, rawComparisonCounterparty) + ? rawComparisonCounterparty + : null; const comparisonOrganization = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true ? derivedOrganizationScope ?? toNonEmptyString(filtersWithDerivedScope.organization) : null; diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js index 30b0437..2558c1b 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -411,11 +411,25 @@ function isReferentialCounterpartyPlaceholder(value) { } function normalizeCounterpartyCandidate(value, toNonEmptyString) { const text = toNonEmptyString(value); - if (!text || isReferentialCounterpartyPlaceholder(text)) { + if (!text || isReferentialCounterpartyPlaceholder(text) || isCounterpartyRoleFragment(text)) { return null; } return text; } +function isCounterpartyRoleFragment(value) { + const text = (0, addressTextRepair_1.normalizeRussianComparableText)(value); + if (!text) { + return false; + } + const hasRoleWord = /(?:^|\s)(?:клиент|поставщик|контрагент|получатель|источник|покупатель|заказчик)(?:\s|$)/u.test(text); + if (!hasRoleWord) { + return false; + } + const hasRankingWord = /(?:^|\s)(?:главн[а-яёa-z0-9_-]*|крупнейш[а-яёa-z0-9_-]*|основн[а-яёa-z0-9_-]*|топ(?:-\d+)?)(?:\s|$)/u.test(text); + const hasRoleOnlyExplanation = /(?:^|\s)(?:клиент|поставщик)(?:\s|$).{0,40}(?:^|\s)как(?:\s|$).{0,30}(?:^|\s)рол/u.test(text); + const endsWithDanglingPreposition = /(?:^|\s)(?:в|за|по|на|от|для|к)$/u.test(text); + return hasRoleOnlyExplanation || (hasRankingWord && (endsWithDanglingPreposition || text.length <= 48)); +} function sameCounterpartyCandidate(left, right) { return Boolean(left && right && @@ -590,6 +604,9 @@ function resolveAddressDebugCarryoverFilters(debug, toNonEmptyString = fallbackT const organization = readAddressDebugOrganization(debug, toNonEmptyString); const preferGroundedDiscoveryCounterparty = hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString) && Boolean(counterparty); const existingCounterparty = normalizeCounterpartyCandidate(nextFilters.counterparty, toNonEmptyString); + if (!existingCounterparty) { + delete nextFilters.counterparty; + } if (counterparty && (preferGroundedDiscoveryCounterparty || !existingCounterparty)) { nextFilters.counterparty = counterparty; } diff --git a/llm_normalizer/backend/src/services/addressNavigationState.ts b/llm_normalizer/backend/src/services/addressNavigationState.ts index 8adfe4b..42444df 100644 --- a/llm_normalizer/backend/src/services/addressNavigationState.ts +++ b/llm_normalizer/backend/src/services/addressNavigationState.ts @@ -1,6 +1,7 @@ import { nanoid } from "nanoid"; import type { AssistantConversationItem } from "../types/assistant"; import type { AddressIntent } from "../types/addressQuery"; +import { normalizeRussianComparableText } from "./addressTextRepair"; import { readAddressDebugCounterparty, readAddressDebugItem, @@ -346,6 +347,43 @@ function normalizeFilters(value: unknown): Record { return { ...record }; } +function isBusinessOverviewRoleCounterpartyFragment(value: unknown): boolean { + const text = normalizeRussianComparableText(value); + if (!text) { + return false; + } + const hasRoleWord = /(?:^|\s)(?:клиент|поставщик|контрагент|получатель|источник|покупатель|заказчик)(?:\s|$)/u.test(text); + if (!hasRoleWord) { + return false; + } + const hasRankingWord = /(?:^|\s)(?:главн[а-яёa-z0-9_-]*|крупнейш[а-яёa-z0-9_-]*|основн[а-яёa-z0-9_-]*|топ(?:-\d+)?)(?:\s|$)/u.test(text); + const hasRoleOnlyExplanation = /(?:^|\s)(?:клиент|поставщик)(?:\s|$).{0,40}(?:^|\s)как(?:\s|$).{0,30}(?:^|\s)рол/u.test(text); + const endsWithDanglingPreposition = /(?:^|\s)(?:в|за|по|на|от|для|к)$/u.test(text); + return hasRoleOnlyExplanation || (hasRankingWord && (endsWithDanglingPreposition || text.length <= 48)); +} + +function shouldSuppressBusinessOverviewCounterpartyFocus( + debug: Record, + counterparty: unknown +): boolean { + return ( + toNonEmptyString(debug.mcp_discovery_selected_chain_id) === "business_overview" && + debug.mcp_discovery_response_applied === true && + isBusinessOverviewRoleCounterpartyFragment(counterparty) + ); +} + +function sanitizeBusinessOverviewNavigationFilters( + filters: Record, + debug: Record +): Record { + if (!shouldSuppressBusinessOverviewCounterpartyFocus(debug, filters.counterparty)) { + return filters; + } + const { counterparty: _counterparty, ...rest } = filters; + return rest; +} + function resolveNavigationAction(debug: Record, hasFocusObject: boolean): AddressNavigationAction { const continuationContract = toObject(debug.dialog_continuation_contract_v2); const decision = toNonEmptyString(continuationContract?.decision); @@ -426,7 +464,7 @@ function buildFocusObjectFromDebug(debug: Record, resultSetId: const selectedDiscoveryChain = toNonEmptyString(debug.mcp_discovery_selected_chain_id); if (selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true) { const counterparty = readAddressDebugCounterparty(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug); - if (counterparty) { + if (counterparty && !shouldSuppressBusinessOverviewCounterpartyFocus(debug, counterparty)) { return buildFocusObject("counterparty", counterparty, resultSetId, createdAt); } const organization = readAddressDebugOrganization(debug, toNonEmptyString); @@ -674,7 +712,7 @@ export function evolveAddressNavigationStateWithAssistantItem( const createdAt = toNonEmptyString(item.created_at) ?? new Date().toISOString(); const resultSetId = `rs-${item.message_id}`; const routeId = toNonEmptyString(debug.selected_recipe) ?? selectedDiscoveryChain; - const filters = normalizeFilters(debug.extracted_filters); + const filters = sanitizeBusinessOverviewNavigationFilters(normalizeFilters(debug.extracted_filters), debug); const derivedOrganizationScope = resolveDerivedOrganizationScope(debug, filters, item.text) ?? readAddressDebugOrganization(debug, toNonEmptyString); const derivedCounterpartyScope = @@ -719,10 +757,14 @@ export function evolveAddressNavigationStateWithAssistantItem( debugFocusObject, primaryEntityFocusObject ); - const comparisonCounterparty = + const rawComparisonCounterparty = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true ? readNavigationDiscoveryCounterparty(debug) : null; + const comparisonCounterparty = + rawComparisonCounterparty && !shouldSuppressBusinessOverviewCounterpartyFocus(debug, rawComparisonCounterparty) + ? rawComparisonCounterparty + : null; const comparisonOrganization = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true ? derivedOrganizationScope ?? toNonEmptyString(filtersWithDerivedScope.organization) diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index 886cf27..a687437 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -624,12 +624,27 @@ function normalizeCounterpartyCandidate( toNonEmptyString: (value: unknown) => string | null ): string | null { const text = toNonEmptyString(value); - if (!text || isReferentialCounterpartyPlaceholder(text)) { + if (!text || isReferentialCounterpartyPlaceholder(text) || isCounterpartyRoleFragment(text)) { return null; } return text; } +function isCounterpartyRoleFragment(value: unknown): boolean { + const text = normalizeRussianComparableText(value); + if (!text) { + return false; + } + const hasRoleWord = /(?:^|\s)(?:клиент|поставщик|контрагент|получатель|источник|покупатель|заказчик)(?:\s|$)/u.test(text); + if (!hasRoleWord) { + return false; + } + const hasRankingWord = /(?:^|\s)(?:главн[а-яёa-z0-9_-]*|крупнейш[а-яёa-z0-9_-]*|основн[а-яёa-z0-9_-]*|топ(?:-\d+)?)(?:\s|$)/u.test(text); + const hasRoleOnlyExplanation = /(?:^|\s)(?:клиент|поставщик)(?:\s|$).{0,40}(?:^|\s)как(?:\s|$).{0,30}(?:^|\s)рол/u.test(text); + const endsWithDanglingPreposition = /(?:^|\s)(?:в|за|по|на|от|для|к)$/u.test(text); + return hasRoleOnlyExplanation || (hasRankingWord && (endsWithDanglingPreposition || text.length <= 48)); +} + function sameCounterpartyCandidate( left: string | null, right: string | null @@ -877,6 +892,9 @@ export function resolveAddressDebugCarryoverFilters( const preferGroundedDiscoveryCounterparty = hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString) && Boolean(counterparty); const existingCounterparty = normalizeCounterpartyCandidate(nextFilters.counterparty, toNonEmptyString); + if (!existingCounterparty) { + delete nextFilters.counterparty; + } if (counterparty && (preferGroundedDiscoveryCounterparty || !existingCounterparty)) { nextFilters.counterparty = counterparty; } diff --git a/llm_normalizer/backend/tests/addressNavigationState.test.ts b/llm_normalizer/backend/tests/addressNavigationState.test.ts index a753562..ab39e75 100644 --- a/llm_normalizer/backend/tests/addressNavigationState.test.ts +++ b/llm_normalizer/backend/tests/addressNavigationState.test.ts @@ -105,6 +105,68 @@ describe("address navigation state", () => { expect(evolved.session_context.date_scope.period_to).toBe("2020-12-31"); }); + it("does not store broad money role wording as a counterparty focus object", () => { + const base = createEmptyAddressNavigationState("asst-bo-money", "2026-04-12T10:00:00.000Z"); + const organization = 'ООО "Альтернатива Плюс"'; + const assistantItem = { + message_id: "msg-bo-money", + session_id: "asst-bo-money", + role: "assistant", + text: + "По компании ООО \"Альтернатива Плюс\" за 2020 получили 47 628 853,03 руб.; крупнейший небанковский входящий контрагент: Группа СВК.", + reply_type: "partial_coverage", + created_at: "2026-04-12T10:11:00.000Z", + trace_id: "address-bo-money", + debug: { + detected_mode: "address_query", + detected_intent: "unknown", + selected_recipe: null, + extracted_filters: { + counterparty: "и главный поставщик в", + organization, + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + anchor_type: "unknown", + mcp_discovery_response_applied: true, + mcp_discovery_selected_chain_id: "business_overview", + assistant_mcp_discovery_entry_point_v1: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + entry_status: "bridge_executed", + turn_input: { + turn_meaning_ref: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_organization_scope: organization, + explicit_date_scope: "2020" + }, + data_need_graph: { + business_fact_family: "business_overview", + subject_candidates: [] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + } + } + }, + dialog_continuation_contract_v2: { + decision: "continue_previous" + } + } + } as any; + + const evolved = evolveAddressNavigationStateWithAssistantItem(base, assistantItem, 2); + + expect(evolved.result_sets[0]?.filters.counterparty).toBeUndefined(); + expect(evolved.result_sets[0]?.filters.organization).toBe(organization); + expect(evolved.session_context.active_focus_object?.object_type).toBe("organization"); + expect(evolved.session_context.active_focus_object?.label).toBe(organization); + expect(evolved.navigation_history[0]?.target_object_id).toBe(`organization:${organization}`.toLowerCase()); + }); + it("captures counterparty focus from applied discovery turns even when the response stayed in chat mode", () => { const base = createEmptyAddressNavigationState("asst-vf", "2026-04-12T10:00:00.000Z"); const assistantItem = { diff --git a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts index 9e0142b..3f91ade 100644 --- a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts @@ -150,6 +150,33 @@ describe("assistantContinuityPolicy organization authority", () => { }); }); + it("does not expose broad money role fragments as counterparty carryover", () => { + const debug = { + execution_lane: "address_query", + detected_intent: "business_overview", + extracted_filters: { + counterparty: "и главный поставщик в", + organization: 'ООО "Альтернатива Плюс"', + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + anchor_type: "counterparty", + anchor_value_raw: "и главный поставщик в", + anchor_value_resolved: "и главный поставщик в" + }; + + expect(readAddressDebugCounterparty(debug)).toBeNull(); + expect(resolveAddressDebugCarryoverFilters(debug)).toEqual({ + organization: 'ООО "Альтернатива Плюс"', + period_from: "2020-01-01", + period_to: "2020-12-31" + }); + expect(resolveAddressDebugAnchorContext(debug)).toEqual({ + anchorType: "counterparty", + anchorValue: null + }); + }); + it("hydrates intent and carryover filters from grounded MCP discovery payout scope", () => { const debug = { execution_lane: "living_chat",