Очистить память ассистента от ролевых фрагментов контрагентов

This commit is contained in:
dctouch 2026-06-15 18:57:11 +03:00
parent 1507ca2bb9
commit bbb3620956
6 changed files with 204 additions and 8 deletions

View File

@ -5,6 +5,7 @@ exports.cloneAddressNavigationState = cloneAddressNavigationState;
exports.normalizeAddressNavigationState = normalizeAddressNavigationState; exports.normalizeAddressNavigationState = normalizeAddressNavigationState;
exports.evolveAddressNavigationStateWithAssistantItem = evolveAddressNavigationStateWithAssistantItem; exports.evolveAddressNavigationStateWithAssistantItem = evolveAddressNavigationStateWithAssistantItem;
const nanoid_1 = require("nanoid"); const nanoid_1 = require("nanoid");
const addressTextRepair_1 = require("./addressTextRepair");
const assistantContinuityPolicy_1 = require("./assistantContinuityPolicy"); const assistantContinuityPolicy_1 = require("./assistantContinuityPolicy");
const addressNavigation_1 = require("../types/addressNavigation"); const addressNavigation_1 = require("../types/addressNavigation");
const MAX_RESULT_SETS = 40; const MAX_RESULT_SETS = 40;
@ -295,6 +296,32 @@ function normalizeFilters(value) {
} }
return { ...record }; 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) { function resolveNavigationAction(debug, hasFocusObject) {
const continuationContract = toObject(debug.dialog_continuation_contract_v2); const continuationContract = toObject(debug.dialog_continuation_contract_v2);
const decision = toNonEmptyString(continuationContract?.decision); const decision = toNonEmptyString(continuationContract?.decision);
@ -361,7 +388,7 @@ function buildFocusObjectFromDebug(debug, resultSetId, createdAt) {
const selectedDiscoveryChain = toNonEmptyString(debug.mcp_discovery_selected_chain_id); const selectedDiscoveryChain = toNonEmptyString(debug.mcp_discovery_selected_chain_id);
if (selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true) { if (selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true) {
const counterparty = (0, assistantContinuityPolicy_1.readAddressDebugCounterparty)(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug); const counterparty = (0, assistantContinuityPolicy_1.readAddressDebugCounterparty)(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug);
if (counterparty) { if (counterparty && !shouldSuppressBusinessOverviewCounterpartyFocus(debug, counterparty)) {
return buildFocusObject("counterparty", counterparty, resultSetId, createdAt); return buildFocusObject("counterparty", counterparty, resultSetId, createdAt);
} }
const organization = (0, assistantContinuityPolicy_1.readAddressDebugOrganization)(debug, toNonEmptyString); 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 createdAt = toNonEmptyString(item.created_at) ?? new Date().toISOString();
const resultSetId = `rs-${item.message_id}`; const resultSetId = `rs-${item.message_id}`;
const routeId = toNonEmptyString(debug.selected_recipe) ?? selectedDiscoveryChain; 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 derivedOrganizationScope = resolveDerivedOrganizationScope(debug, filters, item.text) ?? (0, assistantContinuityPolicy_1.readAddressDebugOrganization)(debug, toNonEmptyString);
const derivedCounterpartyScope = selectedDiscoveryChain === "value_flow_comparison" && debug.mcp_discovery_response_applied === true const derivedCounterpartyScope = selectedDiscoveryChain === "value_flow_comparison" && debug.mcp_discovery_response_applied === true
? (0, assistantContinuityPolicy_1.readAddressDebugCounterparty)(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug) ? (0, assistantContinuityPolicy_1.readAddressDebugCounterparty)(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug)
@ -610,9 +637,12 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) {
const debugFocusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt); const debugFocusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt);
const primaryEntityFocusObject = buildFocusObjectFromPrimaryEntityRef(resultSet, createdAt); const primaryEntityFocusObject = buildFocusObjectFromPrimaryEntityRef(resultSet, createdAt);
const focusObject = resolveFocusObjectForNavigation(state, intent, resultSet, debugFocusObject, primaryEntityFocusObject); 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) ? readNavigationDiscoveryCounterparty(debug)
: null; : null;
const comparisonCounterparty = rawComparisonCounterparty && !shouldSuppressBusinessOverviewCounterpartyFocus(debug, rawComparisonCounterparty)
? rawComparisonCounterparty
: null;
const comparisonOrganization = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true const comparisonOrganization = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true
? derivedOrganizationScope ?? toNonEmptyString(filtersWithDerivedScope.organization) ? derivedOrganizationScope ?? toNonEmptyString(filtersWithDerivedScope.organization)
: null; : null;

View File

@ -411,11 +411,25 @@ function isReferentialCounterpartyPlaceholder(value) {
} }
function normalizeCounterpartyCandidate(value, toNonEmptyString) { function normalizeCounterpartyCandidate(value, toNonEmptyString) {
const text = toNonEmptyString(value); const text = toNonEmptyString(value);
if (!text || isReferentialCounterpartyPlaceholder(text)) { if (!text || isReferentialCounterpartyPlaceholder(text) || isCounterpartyRoleFragment(text)) {
return null; return null;
} }
return text; 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) { function sameCounterpartyCandidate(left, right) {
return Boolean(left && return Boolean(left &&
right && right &&
@ -590,6 +604,9 @@ function resolveAddressDebugCarryoverFilters(debug, toNonEmptyString = fallbackT
const organization = readAddressDebugOrganization(debug, toNonEmptyString); const organization = readAddressDebugOrganization(debug, toNonEmptyString);
const preferGroundedDiscoveryCounterparty = hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString) && Boolean(counterparty); const preferGroundedDiscoveryCounterparty = hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString) && Boolean(counterparty);
const existingCounterparty = normalizeCounterpartyCandidate(nextFilters.counterparty, toNonEmptyString); const existingCounterparty = normalizeCounterpartyCandidate(nextFilters.counterparty, toNonEmptyString);
if (!existingCounterparty) {
delete nextFilters.counterparty;
}
if (counterparty && (preferGroundedDiscoveryCounterparty || !existingCounterparty)) { if (counterparty && (preferGroundedDiscoveryCounterparty || !existingCounterparty)) {
nextFilters.counterparty = counterparty; nextFilters.counterparty = counterparty;
} }

View File

@ -1,6 +1,7 @@
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import type { AssistantConversationItem } from "../types/assistant"; import type { AssistantConversationItem } from "../types/assistant";
import type { AddressIntent } from "../types/addressQuery"; import type { AddressIntent } from "../types/addressQuery";
import { normalizeRussianComparableText } from "./addressTextRepair";
import { import {
readAddressDebugCounterparty, readAddressDebugCounterparty,
readAddressDebugItem, readAddressDebugItem,
@ -346,6 +347,43 @@ function normalizeFilters(value: unknown): Record<string, unknown> {
return { ...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<string, unknown>,
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<string, unknown>,
debug: Record<string, unknown>
): Record<string, unknown> {
if (!shouldSuppressBusinessOverviewCounterpartyFocus(debug, filters.counterparty)) {
return filters;
}
const { counterparty: _counterparty, ...rest } = filters;
return rest;
}
function resolveNavigationAction(debug: Record<string, unknown>, hasFocusObject: boolean): AddressNavigationAction { function resolveNavigationAction(debug: Record<string, unknown>, hasFocusObject: boolean): AddressNavigationAction {
const continuationContract = toObject(debug.dialog_continuation_contract_v2); const continuationContract = toObject(debug.dialog_continuation_contract_v2);
const decision = toNonEmptyString(continuationContract?.decision); const decision = toNonEmptyString(continuationContract?.decision);
@ -426,7 +464,7 @@ function buildFocusObjectFromDebug(debug: Record<string, unknown>, resultSetId:
const selectedDiscoveryChain = toNonEmptyString(debug.mcp_discovery_selected_chain_id); const selectedDiscoveryChain = toNonEmptyString(debug.mcp_discovery_selected_chain_id);
if (selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true) { if (selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true) {
const counterparty = readAddressDebugCounterparty(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug); const counterparty = readAddressDebugCounterparty(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug);
if (counterparty) { if (counterparty && !shouldSuppressBusinessOverviewCounterpartyFocus(debug, counterparty)) {
return buildFocusObject("counterparty", counterparty, resultSetId, createdAt); return buildFocusObject("counterparty", counterparty, resultSetId, createdAt);
} }
const organization = readAddressDebugOrganization(debug, toNonEmptyString); const organization = readAddressDebugOrganization(debug, toNonEmptyString);
@ -674,7 +712,7 @@ export function evolveAddressNavigationStateWithAssistantItem(
const createdAt = toNonEmptyString(item.created_at) ?? new Date().toISOString(); const createdAt = toNonEmptyString(item.created_at) ?? new Date().toISOString();
const resultSetId = `rs-${item.message_id}`; const resultSetId = `rs-${item.message_id}`;
const routeId = toNonEmptyString(debug.selected_recipe) ?? selectedDiscoveryChain; const routeId = toNonEmptyString(debug.selected_recipe) ?? selectedDiscoveryChain;
const filters = normalizeFilters(debug.extracted_filters); const filters = sanitizeBusinessOverviewNavigationFilters(normalizeFilters(debug.extracted_filters), debug);
const derivedOrganizationScope = const derivedOrganizationScope =
resolveDerivedOrganizationScope(debug, filters, item.text) ?? readAddressDebugOrganization(debug, toNonEmptyString); resolveDerivedOrganizationScope(debug, filters, item.text) ?? readAddressDebugOrganization(debug, toNonEmptyString);
const derivedCounterpartyScope = const derivedCounterpartyScope =
@ -719,10 +757,14 @@ export function evolveAddressNavigationStateWithAssistantItem(
debugFocusObject, debugFocusObject,
primaryEntityFocusObject primaryEntityFocusObject
); );
const comparisonCounterparty = const rawComparisonCounterparty =
selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true
? readNavigationDiscoveryCounterparty(debug) ? readNavigationDiscoveryCounterparty(debug)
: null; : null;
const comparisonCounterparty =
rawComparisonCounterparty && !shouldSuppressBusinessOverviewCounterpartyFocus(debug, rawComparisonCounterparty)
? rawComparisonCounterparty
: null;
const comparisonOrganization = const comparisonOrganization =
selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true
? derivedOrganizationScope ?? toNonEmptyString(filtersWithDerivedScope.organization) ? derivedOrganizationScope ?? toNonEmptyString(filtersWithDerivedScope.organization)

View File

@ -624,12 +624,27 @@ function normalizeCounterpartyCandidate(
toNonEmptyString: (value: unknown) => string | null toNonEmptyString: (value: unknown) => string | null
): string | null { ): string | null {
const text = toNonEmptyString(value); const text = toNonEmptyString(value);
if (!text || isReferentialCounterpartyPlaceholder(text)) { if (!text || isReferentialCounterpartyPlaceholder(text) || isCounterpartyRoleFragment(text)) {
return null; return null;
} }
return text; 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( function sameCounterpartyCandidate(
left: string | null, left: string | null,
right: string | null right: string | null
@ -877,6 +892,9 @@ export function resolveAddressDebugCarryoverFilters(
const preferGroundedDiscoveryCounterparty = const preferGroundedDiscoveryCounterparty =
hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString) && Boolean(counterparty); hasGroundedDiscoveryBusinessAnswer(debug, toNonEmptyString) && Boolean(counterparty);
const existingCounterparty = normalizeCounterpartyCandidate(nextFilters.counterparty, toNonEmptyString); const existingCounterparty = normalizeCounterpartyCandidate(nextFilters.counterparty, toNonEmptyString);
if (!existingCounterparty) {
delete nextFilters.counterparty;
}
if (counterparty && (preferGroundedDiscoveryCounterparty || !existingCounterparty)) { if (counterparty && (preferGroundedDiscoveryCounterparty || !existingCounterparty)) {
nextFilters.counterparty = counterparty; nextFilters.counterparty = counterparty;
} }

View File

@ -105,6 +105,68 @@ describe("address navigation state", () => {
expect(evolved.session_context.date_scope.period_to).toBe("2020-12-31"); 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", () => { 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 base = createEmptyAddressNavigationState("asst-vf", "2026-04-12T10:00:00.000Z");
const assistantItem = { const assistantItem = {

View File

@ -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", () => { it("hydrates intent and carryover filters from grounded MCP discovery payout scope", () => {
const debug = { const debug = {
execution_lane: "living_chat", execution_lane: "living_chat",