From 1507ca2bb9f992855eef03876f99955d2b322a31 Mon Sep 17 00:00:00 2001 From: dctouch Date: Mon, 15 Jun 2026 18:29:22 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=BA=D1=80=D0=B5=D0=BF=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B1=D0=B8=D0=B7=D0=BD=D0=B5=D1=81-=D0=BE?= =?UTF-8?q?=D0=B1=D0=B7=D0=BE=D1=80=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D1=82=D1=81=D0=BA=D0=B8=D1=85=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assistantMcpDiscoveryDataNeedGraph.js | 34 +++- .../assistantMcpDiscoveryResponsePolicy.js | 50 +++++ .../assistantMcpDiscoveryDataNeedGraph.ts | 46 ++++- .../assistantMcpDiscoveryResponsePolicy.ts | 69 +++++++ ...assistantMcpDiscoveryDataNeedGraph.test.ts | 58 ++++++ ...ssistantMcpDiscoveryResponsePolicy.test.ts | 171 ++++++++++++++++++ 6 files changed, 424 insertions(+), 4 deletions(-) diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js index 5275cd5..7912bcd 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js @@ -2,6 +2,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION = void 0; exports.buildAssistantMcpDiscoveryDataNeedGraph = buildAssistantMcpDiscoveryDataNeedGraph; +const addressTextRepair_1 = require("./addressTextRepair"); exports.ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION = "assistant_data_need_graph_v1"; function toNonEmptyString(value) { if (value === null || value === undefined) { @@ -203,6 +204,28 @@ function hasDetailedBusinessOverviewMoneyBreakdownHint(rawUtterance) { } return /(?:\u0440\u0430\u0441\u043a\u0440\u043e\p{L}*|\u043f\u043e\u0434\u0440\u043e\u0431\p{L}*|\u0440\u0430\u0437\u0431\u0435\p{L}*|\u0440\u0430\u0437\u0432\u0435\u0440\u043d\p{L}*|\u043a\u0442\u043e\s+\u0433\u043b\u0430\u0432\p{L}*|\u0433\u043b\u0430\u0432\p{L}*\s+\u043a\u043b\u0438\u0435\u043d\p{L}*|\u0433\u043b\u0430\u0432\p{L}*\s+\u043f\u043e\u0441\u0442\u0430\u0432\p{L}*|\u0447\u0438\u0441\u0442\p{L}*\s+\u0434\u0435\u043d\u0435\u0436\p{L}*\s+\u043f\u043e\u0442\u043e\p{L}*|\u043f\u043e\u043b\u0443\u0447\p{L}*[\s\S]{0,80}\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u0432\u0445\u043e\u0434\p{L}*[\s\S]{0,80}\u0438\u0441\u0445\u043e\u0434\p{L}*|detail|breakdown|drill\s*down|main\s+customer|main\s+supplier)/iu.test(rawUtterance); } +function hasWideBusinessOverviewSurfaceHint(rawUtterance) { + if (!rawUtterance) { + return false; + } + const textVariants = Array.from(new Set([rawUtterance, (0, addressTextRepair_1.normalizeRussianComparableText)(rawUtterance)])); + if (textVariants.some((text) => /(?:\u043d\u0435\s+\u043e\u0431\u0437\u043e\u0440|\u043f\u0440\u043e\u0441\u0442\u043e\s+\u0434\u0435\u043d\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0434\u0435\u043d\p{L}*)/iu.test(text))) { + return false; + } + const hasBroadCue = textVariants.some((text) => /(?:\u0431\u0438\u0437\u043d\u0435\u0441[-\s]*\u043e\u0431\u0437\u043e\u0440|\u043e\u0431\u0437\u043e\u0440\s+\u0431\u0438\u0437\u043d\u0435\u0441|\u0432\u0437\u0440\u043e\u0441\u043b\p{L}*\s+\u0431\u0438\u0437\u043d\u0435\u0441|\u0434\u0435\u044f\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\p{L}*\s+\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0446\u0435\u043d\p{L}*\s+\u0431\u0438\u0437\u043d\u0435\u0441|business\s+overview|business\s+review|company\s+overview)/iu.test(text)); + if (!hasBroadCue) { + return false; + } + const surfaceMarkers = [ + /(?:\u043d\u0434\u0441|vat|\u043d\u0430\u043b\u043e\u0433\p{L}*)/iu, + /(?:\u0434\u043e\u043b\u0433\p{L}*|\u0437\u0430\u0434\u043e\u043b\u0436\p{L}*|\u0434\u0435\u0431\u0438\u0442\u043e\u0440|\u043a\u0440\u0435\u0434\u0438\u0442\u043e\u0440)/iu, + /(?:\u0441\u043a\u043b\u0430\u0434|\u043e\u0441\u0442\u0430\u0442\u043a|\u0437\u0430\u043f\u0430\u0441|\u0442\u043e\u0432\u0430\u0440\p{L}*)/iu, + /(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b)/iu, + /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\u043b\u0443\u0447\u0430\u0442\u0435\u043b)/iu, + /(?:\u0447\u0442\u043e\s+(?:\u043f\u043e\u043a\u0430\s+)?\u043d\u0435\u043b\u044c\u0437\u044f\s+\u0443\u0442\u0432\u0435\u0440\u0436\u0434|\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434|\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d|\u0433\u0440\u0430\u043d\u0438\u0446\p{L}*)/iu + ]; + return textVariants.some((text) => surfaceMarkers.filter((marker) => marker.test(text)).length >= 3); +} function supportsOrganizationScopedOpenTotal(action) { return action === "turnover" || action === "payout"; } @@ -421,7 +444,8 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { const rawAction = lower(turnMeaning?.asked_action_family); const unsupported = lower(turnMeaning?.unsupported_but_understood_family); const rawUtterance = lower(input.rawUtterance); - const rawQuestionSignal = lower([input.rawUtterance, turnMeaning?.raw_message, turnMeaning?.effective_message].join(" ")); + const rawQuestionSource = [input.rawUtterance, turnMeaning?.raw_message, turnMeaning?.effective_message].join(" "); + const rawQuestionSignal = lower(rawQuestionSource); const aggregationAxis = lower(turnMeaning?.asked_aggregation_axis); const seededRankingNeed = toNonEmptyString(turnMeaning?.seeded_ranking_need); const explicitDateScope = toNonEmptyString(turnMeaning?.explicit_date_scope); @@ -439,10 +463,13 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { }); const oneSidedValueAction = inferredOpenScopeOneSidedValueAction(rawQuestionSignal); const detailedBusinessOverviewMoneyBreakdownHint = hasDetailedBusinessOverviewMoneyBreakdownHint(rawQuestionSignal); + const wideBusinessOverviewSurfaceHint = hasWideBusinessOverviewSurfaceHint(rawQuestionSource); const oneSidedBusinessOverviewValueFlowOverride = initialBusinessFactFamily === "business_overview" && Boolean(oneSidedValueAction) && !detailedBusinessOverviewMoneyBreakdownHint; - const bidirectionalBusinessOverviewValueFlowOverride = initialBusinessFactFamily === "business_overview" && hasIncomingVsOutgoingComparisonHint(rawQuestionSignal); + const bidirectionalBusinessOverviewValueFlowOverride = initialBusinessFactFamily === "business_overview" && + hasIncomingVsOutgoingComparisonHint(rawQuestionSignal) && + !wideBusinessOverviewSurfaceHint; const oneSidedValueFlowActionOverride = initialBusinessFactFamily === "value_flow" && Boolean(oneSidedValueAction) && (rawAction === "counterparty_value_or_turnover" || rawAction === "counterparty_value"); @@ -574,6 +601,9 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { if (businessFactFamily === "business_overview" && !explicitDateScope) { pushReason(reasonCodes, "data_need_graph_business_overview_defaults_to_all_time_scope"); } + if (initialBusinessFactFamily === "business_overview" && wideBusinessOverviewSurfaceHint) { + pushReason(reasonCodes, "data_need_graph_business_overview_wide_surface_preserved"); + } if (directBusinessOverviewMoneyAnswerHint) { pushReason(reasonCodes, "data_need_graph_business_overview_direct_money_answer"); } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index 1bdc822..66dc047 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -364,6 +364,51 @@ function hasGroundedMovementCandidatePriorityOverCurrentInventoryItemClarificati const boundedMovementAnswerAllowed = bridge?.user_facing_response_allowed === true && bridge?.business_fact_answer_allowed === true; return asksForMovements && staleInventoryItemClarification && mcpExecutionPerformed && boundedMovementAnswerAllowed; } +function hasBusinessOverviewBoundaryCandidatePriorityOverCurrentInventoryItemClarification(input, entryPoint, candidate) { + if (!hasCurrentClarificationRequiredAddressReply(input)) { + return false; + } + if (candidate.candidate_status !== "clarification_candidate" && candidate.candidate_status !== "ready_for_guarded_use") { + return false; + } + if (!candidate.eligible_for_future_hot_runtime || !toNonEmptyString(candidate.reply_text)) { + return false; + } + if (candidate.reply_text && hasInternalMechanics(candidate.reply_text)) { + return false; + } + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + const graph = readDiscoveryDataNeedGraph(entryPoint); + const turnMeaning = readDiscoveryTurnMeaning(entryPoint); + const selectedChainId = readDiscoverySelectedChainId(entryPoint); + const discoveryFamily = toNonEmptyString(graph?.business_fact_family); + const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); + const separateCounterpartyCandidates = readStringArray(turnMeaning?.business_overview_separate_entity_candidates); + const hasPreviousCounterpartyBundle = Boolean(toRecordObject(turnMeaning?.previous_counterparty_value_flow_bundle) || + toRecordObject(turnMeaning?.previous_counterparty_document_bundle)); + const asksForBoundarySummary = Boolean((selectedChainId === "business_overview" || + discoveryFamily === "business_overview" || + askedDomain === "business_overview") && + (separateCounterpartyCandidates.length > 0 || hasPreviousCounterpartyBundle)); + const truthAnswerShape = readTruthAnswerShape(input); + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); + const capabilityId = toNonEmptyString(input.addressRuntimeMeta?.capability_id) ?? + toNonEmptyString(input.addressRuntimeMeta?.capability_contract_id) ?? + toNonEmptyString(truthAnswerShape?.capability_contract_id); + const missingFilters = [ + ...readStringArray(input.addressRuntimeMeta?.missing_required_filters), + ...readStringArray(input.addressRuntimeMeta?.missing_anchors), + ...readStringArray(toRecordObject(input.addressRuntimeMeta?.capability_binding_contract)?.missing_anchors) + ]; + const staleInventoryItemClarification = Boolean(missingFilters.includes("item") && + (detectedIntent?.startsWith("inventory_") || + selectedRecipe?.includes("inventory_") || + capabilityId?.includes("inventory_"))); + return asksForBoundarySummary && staleInventoryItemClarification; +} function hasGroundedValueFlowCandidatePriorityOverCurrentClarification(input, entryPoint, candidate) { if (!hasCurrentClarificationRequiredAddressReply(input)) { return false; @@ -934,11 +979,13 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { const discoveryClarificationCandidatePriority = hasDiscoveryClarificationCandidatePriorityOverCurrentClarification(input, entryPoint, candidate); const groundedValueFlowCandidatePriority = hasGroundedValueFlowCandidatePriorityOverCurrentClarification(input, entryPoint, candidate); const groundedMovementCandidatePriority = hasGroundedMovementCandidatePriorityOverCurrentInventoryItemClarification(input, entryPoint, candidate); + const businessOverviewBoundaryCandidatePriority = hasBusinessOverviewBoundaryCandidatePriorityOverCurrentInventoryItemClarification(input, entryPoint, candidate); const groundedValueFlowRankingCandidatePriority = hasGroundedValueFlowRankingCandidatePriorityOverCurrentExactReply(input, entryPoint, candidate); const currentClarificationProtectsCurrentReply = currentClarificationRequiredAddressReply && !discoveryClarificationCandidatePriority && !groundedValueFlowCandidatePriority && !groundedMovementCandidatePriority && + !businessOverviewBoundaryCandidatePriority && !groundedValueFlowRankingCandidatePriority; const alignedFactualAddressReplyProtectsCurrent = alignedFactualAddressReply && !groundedValueFlowRankingCandidatePriority; const matchedFactualAddressContinuationTargetProtectsCurrent = matchedFactualAddressContinuationTarget && !groundedValueFlowRankingCandidatePriority; @@ -989,6 +1036,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { if (groundedMovementCandidatePriority) { pushReason(reasonCodes, "mcp_discovery_response_policy_grounded_movement_candidate_priority_over_inventory_item_clarification"); } + if (businessOverviewBoundaryCandidatePriority) { + pushReason(reasonCodes, "mcp_discovery_response_policy_business_overview_boundary_candidate_priority_over_inventory_item_clarification"); + } if (groundedValueFlowRankingCandidatePriority) { pushReason(reasonCodes, "mcp_discovery_response_policy_grounded_value_flow_ranking_candidate_priority_over_current_exact_reply"); } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts index 654a056..17fe861 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts @@ -1,4 +1,5 @@ import type { AssistantMcpDiscoveryTurnMeaningRef } from "./assistantMcpDiscoveryPolicy"; +import { normalizeRussianComparableText } from "./addressTextRepair"; export const ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION = "assistant_data_need_graph_v1" as const; @@ -307,6 +308,40 @@ function hasDetailedBusinessOverviewMoneyBreakdownHint(rawUtterance: string): bo ); } +function hasWideBusinessOverviewSurfaceHint(rawUtterance: string): boolean { + if (!rawUtterance) { + return false; + } + const textVariants = Array.from(new Set([rawUtterance, normalizeRussianComparableText(rawUtterance)])); + if ( + textVariants.some((text) => + /(?:\u043d\u0435\s+\u043e\u0431\u0437\u043e\u0440|\u043f\u0440\u043e\u0441\u0442\u043e\s+\u0434\u0435\u043d\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0434\u0435\u043d\p{L}*)/iu.test( + text + ) + ) + ) { + return false; + } + const hasBroadCue = + textVariants.some((text) => + /(?:\u0431\u0438\u0437\u043d\u0435\u0441[-\s]*\u043e\u0431\u0437\u043e\u0440|\u043e\u0431\u0437\u043e\u0440\s+\u0431\u0438\u0437\u043d\u0435\u0441|\u0432\u0437\u0440\u043e\u0441\u043b\p{L}*\s+\u0431\u0438\u0437\u043d\u0435\u0441|\u0434\u0435\u044f\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\p{L}*\s+\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0446\u0435\u043d\p{L}*\s+\u0431\u0438\u0437\u043d\u0435\u0441|business\s+overview|business\s+review|company\s+overview)/iu.test( + text + ) + ); + if (!hasBroadCue) { + return false; + } + const surfaceMarkers = [ + /(?:\u043d\u0434\u0441|vat|\u043d\u0430\u043b\u043e\u0433\p{L}*)/iu, + /(?:\u0434\u043e\u043b\u0433\p{L}*|\u0437\u0430\u0434\u043e\u043b\u0436\p{L}*|\u0434\u0435\u0431\u0438\u0442\u043e\u0440|\u043a\u0440\u0435\u0434\u0438\u0442\u043e\u0440)/iu, + /(?:\u0441\u043a\u043b\u0430\u0434|\u043e\u0441\u0442\u0430\u0442\u043a|\u0437\u0430\u043f\u0430\u0441|\u0442\u043e\u0432\u0430\u0440\p{L}*)/iu, + /(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b)/iu, + /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\u043b\u0443\u0447\u0430\u0442\u0435\u043b)/iu, + /(?:\u0447\u0442\u043e\s+(?:\u043f\u043e\u043a\u0430\s+)?\u043d\u0435\u043b\u044c\u0437\u044f\s+\u0443\u0442\u0432\u0435\u0440\u0436\u0434|\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434|\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d|\u0433\u0440\u0430\u043d\u0438\u0446\p{L}*)/iu + ]; + return textVariants.some((text) => surfaceMarkers.filter((marker) => marker.test(text)).length >= 3); +} + function supportsOrganizationScopedOpenTotal(action: string): boolean { return action === "turnover" || action === "payout"; } @@ -565,7 +600,8 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( const rawAction = lower(turnMeaning?.asked_action_family); const unsupported = lower(turnMeaning?.unsupported_but_understood_family); const rawUtterance = lower(input.rawUtterance); - const rawQuestionSignal = lower([input.rawUtterance, turnMeaning?.raw_message, turnMeaning?.effective_message].join(" ")); + const rawQuestionSource = [input.rawUtterance, turnMeaning?.raw_message, turnMeaning?.effective_message].join(" "); + const rawQuestionSignal = lower(rawQuestionSource); const aggregationAxis = lower(turnMeaning?.asked_aggregation_axis); const seededRankingNeed = toNonEmptyString(turnMeaning?.seeded_ranking_need); const explicitDateScope = toNonEmptyString(turnMeaning?.explicit_date_scope); @@ -583,12 +619,15 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( }); const oneSidedValueAction = inferredOpenScopeOneSidedValueAction(rawQuestionSignal); const detailedBusinessOverviewMoneyBreakdownHint = hasDetailedBusinessOverviewMoneyBreakdownHint(rawQuestionSignal); + const wideBusinessOverviewSurfaceHint = hasWideBusinessOverviewSurfaceHint(rawQuestionSource); const oneSidedBusinessOverviewValueFlowOverride = initialBusinessFactFamily === "business_overview" && Boolean(oneSidedValueAction) && !detailedBusinessOverviewMoneyBreakdownHint; const bidirectionalBusinessOverviewValueFlowOverride = - initialBusinessFactFamily === "business_overview" && hasIncomingVsOutgoingComparisonHint(rawQuestionSignal); + initialBusinessFactFamily === "business_overview" && + hasIncomingVsOutgoingComparisonHint(rawQuestionSignal) && + !wideBusinessOverviewSurfaceHint; const oneSidedValueFlowActionOverride = initialBusinessFactFamily === "value_flow" && Boolean(oneSidedValueAction) && @@ -735,6 +774,9 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( if (businessFactFamily === "business_overview" && !explicitDateScope) { pushReason(reasonCodes, "data_need_graph_business_overview_defaults_to_all_time_scope"); } + if (initialBusinessFactFamily === "business_overview" && wideBusinessOverviewSurfaceHint) { + pushReason(reasonCodes, "data_need_graph_business_overview_wide_surface_preserved"); + } if (directBusinessOverviewMoneyAnswerHint) { pushReason(reasonCodes, "data_need_graph_business_overview_direct_money_answer"); } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index b7d659c..938cfc1 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -526,6 +526,66 @@ function hasGroundedMovementCandidatePriorityOverCurrentInventoryItemClarificati return asksForMovements && staleInventoryItemClarification && mcpExecutionPerformed && boundedMovementAnswerAllowed; } +function hasBusinessOverviewBoundaryCandidatePriorityOverCurrentInventoryItemClarification( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null, + candidate: AssistantMcpDiscoveryResponseCandidateContract +): boolean { + if (!hasCurrentClarificationRequiredAddressReply(input)) { + return false; + } + if (candidate.candidate_status !== "clarification_candidate" && candidate.candidate_status !== "ready_for_guarded_use") { + return false; + } + if (!candidate.eligible_for_future_hot_runtime || !toNonEmptyString(candidate.reply_text)) { + return false; + } + if (candidate.reply_text && hasInternalMechanics(candidate.reply_text)) { + return false; + } + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + + const graph = readDiscoveryDataNeedGraph(entryPoint); + const turnMeaning = readDiscoveryTurnMeaning(entryPoint); + const selectedChainId = readDiscoverySelectedChainId(entryPoint); + const discoveryFamily = toNonEmptyString(graph?.business_fact_family); + const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); + const separateCounterpartyCandidates = readStringArray(turnMeaning?.business_overview_separate_entity_candidates); + const hasPreviousCounterpartyBundle = Boolean( + toRecordObject(turnMeaning?.previous_counterparty_value_flow_bundle) || + toRecordObject(turnMeaning?.previous_counterparty_document_bundle) + ); + const asksForBoundarySummary = Boolean( + (selectedChainId === "business_overview" || + discoveryFamily === "business_overview" || + askedDomain === "business_overview") && + (separateCounterpartyCandidates.length > 0 || hasPreviousCounterpartyBundle) + ); + + const truthAnswerShape = readTruthAnswerShape(input); + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); + const capabilityId = + toNonEmptyString(input.addressRuntimeMeta?.capability_id) ?? + toNonEmptyString(input.addressRuntimeMeta?.capability_contract_id) ?? + toNonEmptyString(truthAnswerShape?.capability_contract_id); + const missingFilters = [ + ...readStringArray(input.addressRuntimeMeta?.missing_required_filters), + ...readStringArray(input.addressRuntimeMeta?.missing_anchors), + ...readStringArray(toRecordObject(input.addressRuntimeMeta?.capability_binding_contract)?.missing_anchors) + ]; + const staleInventoryItemClarification = Boolean( + missingFilters.includes("item") && + (detectedIntent?.startsWith("inventory_") || + selectedRecipe?.includes("inventory_") || + capabilityId?.includes("inventory_")) + ); + + return asksForBoundarySummary && staleInventoryItemClarification; +} + function hasGroundedValueFlowCandidatePriorityOverCurrentClarification( input: ApplyAssistantMcpDiscoveryResponsePolicyInput, entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null, @@ -1268,6 +1328,8 @@ export function applyAssistantMcpDiscoveryResponsePolicy( ); const groundedMovementCandidatePriority = hasGroundedMovementCandidatePriorityOverCurrentInventoryItemClarification(input, entryPoint, candidate); + const businessOverviewBoundaryCandidatePriority = + hasBusinessOverviewBoundaryCandidatePriorityOverCurrentInventoryItemClarification(input, entryPoint, candidate); const groundedValueFlowRankingCandidatePriority = hasGroundedValueFlowRankingCandidatePriorityOverCurrentExactReply(input, entryPoint, candidate); const currentClarificationProtectsCurrentReply = @@ -1275,6 +1337,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( !discoveryClarificationCandidatePriority && !groundedValueFlowCandidatePriority && !groundedMovementCandidatePriority && + !businessOverviewBoundaryCandidatePriority && !groundedValueFlowRankingCandidatePriority; const alignedFactualAddressReplyProtectsCurrent = alignedFactualAddressReply && !groundedValueFlowRankingCandidatePriority; @@ -1341,6 +1404,12 @@ export function applyAssistantMcpDiscoveryResponsePolicy( "mcp_discovery_response_policy_grounded_movement_candidate_priority_over_inventory_item_clarification" ); } + if (businessOverviewBoundaryCandidatePriority) { + pushReason( + reasonCodes, + "mcp_discovery_response_policy_business_overview_boundary_candidate_priority_over_inventory_item_clarification" + ); + } if (groundedValueFlowRankingCandidatePriority) { pushReason( reasonCodes, diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts index 84fa4b9..77b27dc 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts @@ -1,6 +1,11 @@ +import iconv from "iconv-lite"; import { describe, expect, it } from "vitest"; import { buildAssistantMcpDiscoveryDataNeedGraph } from "../src/services/assistantMcpDiscoveryDataNeedGraph"; +function toWin1251Mojibake(value: string): string { + return iconv.decode(Buffer.from(value, "utf8"), "win1251"); +} + describe("assistant MCP discovery data need graph", () => { it("builds a monthly bidirectional value-flow graph from grounded turn meaning", () => { const result = buildAssistantMcpDiscoveryDataNeedGraph({ @@ -403,6 +408,59 @@ describe("assistant MCP discovery data need graph", () => { expect(result.reason_codes).toContain("data_need_graph_comparison_incoming_vs_outgoing"); }); + it("keeps a wide formal business overview on the business-overview graph even when money net is requested", () => { + const result = buildAssistantMcpDiscoveryDataNeedGraph({ + semanticDataNeed: "business overview evidence with bounded analyst interpretation", + rawUtterance: + "\u0414\u0430\u0439 \u0432\u0437\u0440\u043e\u0441\u043b\u044b\u0439 \u0431\u0438\u0437\u043d\u0435\u0441-\u043e\u0431\u0437\u043e\u0440 \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441 \u0437\u0430 2020 \u0433\u043e\u0434 \u043f\u043e \u0434\u0430\u043d\u043d\u044b\u043c 1\u0421: \u043e\u0431\u043e\u0440\u043e\u0442\u044b, \u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0438 \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0434\u0435\u043d\u044c\u0433\u0438, \u043d\u0435\u0442\u0442\u043e, \u041d\u0414\u0421, \u0434\u043e\u043b\u0433\u0438, \u0441\u043a\u043b\u0430\u0434, \u043a\u043b\u0438\u0435\u043d\u0442\u044b, \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a\u0438 \u0438 \u0447\u0442\u043e \u043f\u043e\u043a\u0430 \u043d\u0435\u043b\u044c\u0437\u044f \u0443\u0442\u0432\u0435\u0440\u0436\u0434\u0430\u0442\u044c.", + turnMeaning: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + unsupported_but_understood_family: "broad_business_evaluation", + explicit_date_scope: "2020", + explicit_organization_scope: + "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441" + } + }); + + expect(result.business_fact_family).toBe("business_overview"); + expect(result.action_family).toBe("broad_evaluation"); + expect(result.comparison_need).toBeNull(); + expect(result.subject_candidates).toEqual([]); + expect(result.reason_codes).toContain("data_need_graph_business_overview_wide_surface_preserved"); + expect(result.reason_codes).not.toContain( + "data_need_graph_business_overview_bidirectional_money_comparison_routed_to_value_flow" + ); + }); + + it("keeps a wide formal business overview when the runtime signal is win1251 mojibake", () => { + const wideQuestion = + "\u0414\u0430\u0439 \u0432\u0437\u0440\u043e\u0441\u043b\u044b\u0439 \u0431\u0438\u0437\u043d\u0435\u0441-\u043e\u0431\u0437\u043e\u0440 \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441 \u0437\u0430 2020 \u0433\u043e\u0434 \u043f\u043e \u0434\u0430\u043d\u043d\u044b\u043c 1\u0421: \u043e\u0431\u043e\u0440\u043e\u0442\u044b, \u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0438 \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0434\u0435\u043d\u044c\u0433\u0438, \u043d\u0435\u0442\u0442\u043e, \u041d\u0414\u0421, \u0434\u043e\u043b\u0433\u0438, \u0441\u043a\u043b\u0430\u0434, \u043a\u043b\u0438\u0435\u043d\u0442\u044b, \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a\u0438 \u0438 \u0447\u0442\u043e \u043f\u043e\u043a\u0430 \u043d\u0435\u043b\u044c\u0437\u044f \u0443\u0442\u0432\u0435\u0440\u0436\u0434\u0430\u0442\u044c."; + const legacyQuestion = toWin1251Mojibake(wideQuestion); + const result = buildAssistantMcpDiscoveryDataNeedGraph({ + semanticDataNeed: "business overview evidence with bounded analyst interpretation", + rawUtterance: legacyQuestion, + turnMeaning: { + raw_message: legacyQuestion, + effective_message: legacyQuestion, + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + unsupported_but_understood_family: "broad_business_evaluation", + explicit_date_scope: "2020", + explicit_organization_scope: + "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441" + } + }); + + expect(result.business_fact_family).toBe("business_overview"); + expect(result.action_family).toBe("broad_evaluation"); + expect(result.comparison_need).toBeNull(); + expect(result.reason_codes).toContain("data_need_graph_business_overview_wide_surface_preserved"); + expect(result.reason_codes).not.toContain( + "data_need_graph_business_overview_bidirectional_money_comparison_routed_to_value_flow" + ); + }); + it("treats a generic incoming total as an understood open-scope ask that still needs organization", () => { const result = buildAssistantMcpDiscoveryDataNeedGraph({ semanticDataNeed: "counterparty value-flow evidence", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index f1cc03e..a9f996a 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -525,6 +525,177 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_current_clarification_required_reply"); }); + it("uses a business-overview boundary clarification over a stale inventory-item clarification", () => { + const valueFlowBundle = { + counterparty: "Group SVK", + incoming_customer_revenue: { total_amount_human_ru: "20 653 490 rub." }, + outgoing_supplier_payout: { total_amount_human_ru: "2 129 651 rub." }, + net_amount_human_ru: "18 523 839 rub.", + net_direction: "net_incoming" + }; + const documentBundle = { counterparty: "Group SVK", document_count: 19 }; + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "Need item before I can continue.", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "partial_coverage", + addressRuntimeMeta: { + detected_intent: "inventory_purchase_documents_for_item", + selected_recipe: "address_inventory_purchase_documents_for_item_v1", + capability_id: "inventory_inventory_purchase_documents_for_item", + truth_mode: "clarification_required", + answer_shape: "clarification_required", + missing_required_filters: ["item"], + answer_shape_contract: { + answer_shape: "clarification_required", + reply_type: "partial_coverage", + capability_contract_id: "inventory_inventory_purchase_documents_for_item" + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + business_overview_separate_entity_candidates: ["Group SVK"], + previous_counterparty_value_flow_bundle: valueFlowBundle, + previous_counterparty_document_bundle: documentBundle, + unsupported_but_understood_family: "broad_business_evaluation" + }, + data_need_graph: { + business_fact_family: "business_overview", + action_family: "broad_evaluation", + clarification_gaps: ["organization"], + reason_codes: ["data_need_graph_family_business_overview"] + } + }, + bridge: { + bridge_status: "needs_clarification", + user_facing_response_allowed: true, + business_fact_answer_allowed: false, + requires_user_clarification: true, + answer_draft: { + answer_mode: "needs_clarification", + headline: "Specify organization.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: "Specify organization." + } + } + }) + } + }); + + expect(result.applied).toBe(true); + expect(result.decision).toBe("apply_candidate"); + expect(result.reply_text).toContain("Group SVK"); + expect(result.reply_text).toContain("20 653 490 rub."); + expect(result.reply_text).toContain("19"); + expect(result.reply_text).not.toContain("Need item"); + expect(result.reason_codes).toContain( + "mcp_discovery_response_policy_business_overview_boundary_candidate_priority_over_inventory_item_clarification" + ); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_current_clarification_required_reply"); + }); + + it("uses a ready business-overview boundary answer over a stale inventory-item clarification", () => { + const valueFlowBundle = { + counterparty: "Group SVK", + incoming_customer_revenue: { total_amount_human_ru: "20 653 490 rub." }, + outgoing_supplier_payout: { total_amount_human_ru: "2 129 651 rub." }, + net_amount_human_ru: "18 523 839 rub.", + net_direction: "net_incoming" + }; + const documentBundle = { counterparty: "Group SVK", document_count: 19 }; + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "Need item before I can continue.", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "partial_coverage", + addressRuntimeMeta: { + detected_intent: "inventory_purchase_documents_for_item", + selected_recipe: "address_inventory_purchase_documents_for_item_v1", + capability_id: "inventory_inventory_purchase_documents_for_item", + truth_mode: "clarification_required", + answer_shape: "clarification_required", + missing_required_filters: ["item"], + answer_shape_contract: { + answer_shape: "clarification_required", + reply_type: "partial_coverage", + capability_contract_id: "inventory_inventory_purchase_documents_for_item" + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + hot_runtime_wired: true, + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "Alternative Plus", + business_overview_separate_entity_candidates: ["Group SVK"], + previous_counterparty_value_flow_bundle: valueFlowBundle, + previous_counterparty_document_bundle: documentBundle, + unsupported_but_understood_family: "broad_business_evaluation" + }, + data_need_graph: { + business_fact_family: "business_overview", + action_family: "broad_evaluation", + clarification_gaps: [], + reason_codes: ["data_need_graph_family_business_overview"] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + hot_runtime_wired: true, + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + execution_handoff: { + mcp_execution_performed: true, + can_use_guarded_response: true, + must_keep_internal_mechanics_hidden: true + }, + pilot: { + pilot_scope: "business_overview_route_template_v1", + mcp_execution_performed: true, + derived_business_overview: { + period_scope: "all_time", + incoming_customer_revenue: { total_amount_human_ru: "285 819 547 rub." }, + outgoing_supplier_payout: { total_amount_human_ru: "137 963 720 rub." }, + net_amount_human_ru: "147 855 827 rub.", + net_direction: "net_incoming", + top_customers: [], + top_suppliers: [] + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Company summary.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + } + }); + + expect(result.applied).toBe(true); + expect(result.decision).toBe("apply_candidate"); + expect(result.reply_text).toContain("Alternative Plus"); + expect(result.reply_text).toContain("Group SVK"); + expect(result.reply_text).toContain("20 653 490 rub."); + expect(result.reply_text).not.toContain("Need item"); + expect(result.reason_codes).toContain( + "mcp_discovery_response_policy_business_overview_boundary_candidate_priority_over_inventory_item_clarification" + ); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_current_clarification_required_reply"); + }); + it("overrides exact document-list replies for explicit counterparty received-paid-net questions", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "Контрагент: Группа СВК. Найдено документов: 19.",