From 4f078d649efd100c4539455dfb7235275fd28012 Mon Sep 17 00:00:00 2001 From: dctouch Date: Wed, 3 Jun 2026 16:34:21 +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=20phase83=20carryover=20=D0=B8=20=D0=B4=D0=B2?= =?UTF-8?q?=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B0=D1=81=D1=81=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...s_phase83_planner_brain_alignment_mix.json | 17 +- .../services/assistantContinuityPolicy.js | 6 + .../assistantMcpDiscoveryResponsePolicy.js | 161 ++++++++++++- .../assistantMcpDiscoveryTurnInputAdapter.js | 3 +- .../services/assistantTransitionPolicy.js | 2 + .../src/services/assistantContinuityPolicy.ts | 11 + .../assistantMcpDiscoveryResponsePolicy.ts | 220 +++++++++++++++++- .../assistantMcpDiscoveryTurnInputAdapter.ts | 3 +- .../src/services/assistantTransitionPolicy.ts | 6 + ...ssistantMcpDiscoveryResponsePolicy.test.ts | 171 ++++++++++++++ ...istantMcpDiscoveryTurnInputAdapter.test.ts | 48 ++++ .../tests/assistantTransitionPolicy.test.ts | 2 + .../data/autorun_generators/history.json | 10 +- ...20260603132809_gen-ag06031328-3e1dd1.json} | 60 ++--- ...20260603132809_gen-ag06031328-3e1dd1.json} | 6 +- 15 files changed, 665 insertions(+), 61 deletions(-) rename llm_normalizer/data/autorun_generators/saved_sessions/{assistant_saved_session_20260603125251_gen-ag06031252-8c5319.json => assistant_saved_session_20260603132809_gen-ag06031328-3e1dd1.json} (89%) rename llm_normalizer/data/eval_cases/{assistant_autogen_saved_user_sessions_20260603125251_gen-ag06031252-8c5319.json => assistant_autogen_saved_user_sessions_20260603132809_gen-ag06031328-3e1dd1.json} (95%) diff --git a/docs/orchestration/address_truth_harness_phase83_planner_brain_alignment_mix.json b/docs/orchestration/address_truth_harness_phase83_planner_brain_alignment_mix.json index a6ce5d3..42150b2 100644 --- a/docs/orchestration/address_truth_harness_phase83_planner_brain_alignment_mix.json +++ b/docs/orchestration/address_truth_harness_phase83_planner_brain_alignment_mix.json @@ -149,6 +149,7 @@ "expected_catalog_selected_matches_top": true, "required_answer_patterns_all": [ "(?i)2020", + "12[ .\\u00a0]?093[ .\\u00a0]?465|12093465", "(?i)нетто|сальдо", "(?i)руб" ], @@ -161,7 +162,12 @@ "forbidden_answer_patterns": [ "(?i)не найден контрагент", "(?i)уточните, какого контрагента", - "(?i)по какому контрагенту" + "(?i)по какому контрагенту", + "2026-06-03", + "2020-01-01\\.\\.2026-06-03", + "20[ .\\u00a0]?653[ .\\u00a0]?490|20653490", + "2[ .\\u00a0]?129[ .\\u00a0]?651|2129651", + "18[ .\\u00a0]?523[ .\\u00a0]?839|18523839" ], "criticality": "critical", "semantic_tags": [ @@ -398,7 +404,14 @@ "expected_catalog_selected_matches_top": true, "required_answer_patterns_all": [ "(?i)2020", - "(?i)кто|контрагент|клиент|принес|доход" + "(?i)кто|контрагент|клиент|принес|доход", + "(?i)группа\\s+свк", + "(?i)12[ .\\u00a0]?093[ .\\u00a0]?465|12093465" + ], + "forbidden_answer_patterns": [ + "(?i)самый\\s+доходный\\s+год", + "(?i)топ-\\d+\\s+лет", + "(?i)лет\\s+по\\s+сумме\\s+поступлений" ], "criticality": "critical", "semantic_tags": [ diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js index c7fcda9..30b0437 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -12,6 +12,7 @@ exports.readAssistantMcpDiscoveryLoopProvidedAxes = readAssistantMcpDiscoveryLoo exports.readAssistantMcpDiscoveryLoopAskedDomainFamily = readAssistantMcpDiscoveryLoopAskedDomainFamily; exports.readAssistantMcpDiscoveryLoopAskedActionFamily = readAssistantMcpDiscoveryLoopAskedActionFamily; exports.readAssistantMcpDiscoveryLoopUnsupportedFamily = readAssistantMcpDiscoveryLoopUnsupportedFamily; +exports.readAssistantMcpDiscoveryLoopExplicitDateScope = readAssistantMcpDiscoveryLoopExplicitDateScope; exports.readAssistantMcpDiscoveryLoopMetadataScopeHint = readAssistantMcpDiscoveryLoopMetadataScopeHint; exports.readAssistantMcpDiscoveryLoopSubjectResolutionOptional = readAssistantMcpDiscoveryLoopSubjectResolutionOptional; exports.readAssistantMcpDiscoveryMetadataRouteFamily = readAssistantMcpDiscoveryMetadataRouteFamily; @@ -197,6 +198,11 @@ function readAssistantMcpDiscoveryLoopAskedActionFamily(debug, toNonEmptyString function readAssistantMcpDiscoveryLoopUnsupportedFamily(debug, toNonEmptyString = fallbackToNonEmptyString) { return toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.unsupported_but_understood_family); } +function readAssistantMcpDiscoveryLoopExplicitDateScope(debug, toNonEmptyString = fallbackToNonEmptyString) { + return (toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.explicit_date_scope) ?? + toNonEmptyString(readAssistantMcpDiscoveryTurnMeaning(debug)?.explicit_date_scope) ?? + toNonEmptyString(readAssistantMcpDiscoveryDataNeedGraph(debug)?.explicit_date_scope)); +} function readAssistantMcpDiscoveryLoopMetadataScopeHint(debug, toNonEmptyString = fallbackToNonEmptyString) { return (toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.metadata_scope_hint) ?? toNonEmptyString(readAssistantMcpDiscoveryTurnMeaning(debug)?.metadata_scope_hint) ?? diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index 9ccb4b1..1bdc822 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -239,6 +239,26 @@ function isOpenScopeValueFlowRanking(entryPoint) { const subjectCandidates = Array.isArray(graph?.subject_candidates) ? graph.subject_candidates : []; return businessFactFamily === "value_flow" && subjectCandidates.length === 0 && Boolean(toNonEmptyString(graph?.ranking_need)); } +function readDiscoverySelectedChainId(entryPoint) { + const bridge = toRecordObject(entryPoint?.bridge); + const pilot = toRecordObject(bridge?.pilot); + const routeCandidate = toRecordObject(bridge?.route_candidate); + return (toNonEmptyString(routeCandidate?.selected_chain_id) ?? + toNonEmptyString(bridge?.selected_chain_id) ?? + toNonEmptyString(pilot?.selected_chain_id)); +} +function isValueFlowRankingDiscoveryTurn(entryPoint) { + const graph = readDiscoveryDataNeedGraph(entryPoint); + const bridge = toRecordObject(entryPoint?.bridge); + const routeCandidate = toRecordObject(bridge?.route_candidate); + const selectedChainId = readDiscoverySelectedChainId(entryPoint); + const graphReasonCodes = readStringArray(graph?.reason_codes); + return Boolean(toNonEmptyString(graph?.business_fact_family) === "value_flow" && + (selectedChainId === "value_flow_ranking" || + toNonEmptyString(routeCandidate?.nearest_catalog_chain_template) === "value_flow_ranking" || + toNonEmptyString(graph?.ranking_need) === "top_desc" || + graphReasonCodes.includes("data_need_graph_ranking_top_desc"))); +} function readTruthAnswerShape(input) { const directShape = toRecordObject(input.addressRuntimeMeta?.answer_shape_contract); if (directShape) { @@ -296,6 +316,54 @@ function hasDiscoveryClarificationCandidatePriorityOverCurrentClarification(inpu capabilityId?.includes("inventory_"))); return asksForMovements && staleInventoryItemClarification; } +function hasGroundedMovementCandidatePriorityOverCurrentInventoryItemClarification(input, entryPoint, candidate) { + if (!hasCurrentClarificationRequiredAddressReply(input)) { + return false; + } + if (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 bridge = toRecordObject(entryPoint?.bridge); + const pilot = toRecordObject(bridge?.pilot); + const handoff = toRecordObject(bridge?.execution_handoff); + const selectedChainId = readDiscoverySelectedChainId(entryPoint); + const discoveryFamily = toNonEmptyString(graph?.business_fact_family); + const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); + const askedAction = toNonEmptyString(turnMeaning?.asked_action_family) ?? toNonEmptyString(graph?.action_family); + const asksForMovements = Boolean(selectedChainId === "movement_evidence" || + discoveryFamily === "movement_evidence" || + askedDomain === "movements" || + askedAction === "list_movements"); + 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_"))); + const mcpExecutionPerformed = handoff?.mcp_execution_performed === true || pilot?.mcp_execution_performed === true; + const boundedMovementAnswerAllowed = bridge?.user_facing_response_allowed === true && bridge?.business_fact_answer_allowed === true; + return asksForMovements && staleInventoryItemClarification && mcpExecutionPerformed && boundedMovementAnswerAllowed; +} function hasGroundedValueFlowCandidatePriorityOverCurrentClarification(input, entryPoint, candidate) { if (!hasCurrentClarificationRequiredAddressReply(input)) { return false; @@ -313,10 +381,7 @@ function hasGroundedValueFlowCandidatePriorityOverCurrentClarification(input, en const bridge = toRecordObject(entryPoint?.bridge); const pilot = toRecordObject(bridge?.pilot); const handoff = toRecordObject(bridge?.execution_handoff); - const routeCandidate = toRecordObject(bridge?.route_candidate); - const selectedChainId = toNonEmptyString(routeCandidate?.selected_chain_id) ?? - toNonEmptyString(bridge?.selected_chain_id) ?? - toNonEmptyString(pilot?.selected_chain_id); + const selectedChainId = readDiscoverySelectedChainId(entryPoint); const pilotScope = toNonEmptyString(pilot?.pilot_scope); const actionFamily = toNonEmptyString(graph?.action_family); const comparisonNeed = toNonEmptyString(graph?.comparison_need); @@ -329,6 +394,70 @@ function hasGroundedValueFlowCandidatePriorityOverCurrentClarification(input, en (handoff?.can_use_guarded_response === true || toNonEmptyString(bridge?.bridge_status) === "answer_draft_ready"); return valueFlowComparison && mcpExecutionPerformed && guardedBusinessAnswerAllowed; } +function hasCounterpartyRankingQuestionSignal(entryPoint) { + const graph = readDiscoveryDataNeedGraph(entryPoint); + const turnMeaning = readDiscoveryTurnMeaning(entryPoint); + const rawMessage = toNonEmptyString(turnMeaning?.raw_message) ?? ""; + const effectiveMessage = toNonEmptyString(turnMeaning?.effective_message) ?? ""; + const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); + const askedAction = toNonEmptyString(turnMeaning?.asked_action_family); + const text = `${rawMessage}\n${effectiveMessage}`.toLowerCase(); + return Boolean(isValueFlowRankingDiscoveryTurn(entryPoint) && + (askedDomain === "counterparty_value" || askedDomain === "counterparty") && + (askedAction === "turnover" || askedAction === "counterparty_value_or_turnover") && + (/\bкто\b/u.test(text) || + text.includes("контрагент") || + text.includes("клиент") || + text.includes("покупател") || + text.includes("заказчик") || + text.includes("принес") || + text.includes("принёс") || + text.includes("больше всего") || + toNonEmptyString(graph?.ranking_need) === "top_desc")); +} +function hasWrongPeriodRankingCurrentReply(input) { + const currentReply = String(input.currentReply ?? "").toLowerCase(); + return (currentReply.includes("самый доходный год") || + currentReply.includes("топ-1 лет") || + currentReply.includes("топ-5 лет") || + currentReply.includes("топ-10 лет") || + /топ-\d+\s+лет/u.test(currentReply) || + currentReply.includes("лет по сумме поступлений") || + currentReply.includes("год по подтвержденным поступлениям")); +} +function hasConfirmedValueFlowRankingEvidence(entryPoint) { + const bridge = toRecordObject(entryPoint?.bridge); + const pilot = toRecordObject(bridge?.pilot); + const handoff = toRecordObject(bridge?.execution_handoff); + const evidence = toRecordObject(pilot?.evidence); + const confirmedFacts = readStringArray(evidence?.confirmed_facts); + const mcpExecutionPerformed = handoff?.mcp_execution_performed === true || pilot?.mcp_execution_performed === true; + const businessAnswerAllowed = bridge?.user_facing_response_allowed === true && bridge?.business_fact_answer_allowed === true; + const confirmedRankingEvidence = Boolean(toNonEmptyString(evidence?.evidence_status) === "confirmed" || + toNonEmptyString(evidence?.answer_permission) === "confirmed_answer" || + confirmedFacts.some((fact) => /ranked by counterparty|рейтинг.*контрагент/iu.test(fact))); + return mcpExecutionPerformed && businessAnswerAllowed && confirmedRankingEvidence; +} +function hasGroundedValueFlowRankingCandidatePriorityOverCurrentExactReply(input, entryPoint, candidate) { + if (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; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + return Boolean(hasCounterpartyRankingQuestionSignal(entryPoint) && + hasWrongPeriodRankingCurrentReply(input) && + hasConfirmedValueFlowRankingEvidence(entryPoint)); +} function hasEffectivelyFactualAddressReply(input) { if (toNonEmptyString(input.currentReplyType) === "factual") { return true; @@ -804,9 +933,17 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { const currentClarificationRequiredAddressReply = hasCurrentClarificationRequiredAddressReply(input); const discoveryClarificationCandidatePriority = hasDiscoveryClarificationCandidatePriorityOverCurrentClarification(input, entryPoint, candidate); const groundedValueFlowCandidatePriority = hasGroundedValueFlowCandidatePriorityOverCurrentClarification(input, entryPoint, candidate); + const groundedMovementCandidatePriority = hasGroundedMovementCandidatePriorityOverCurrentInventoryItemClarification(input, entryPoint, candidate); + const groundedValueFlowRankingCandidatePriority = hasGroundedValueFlowRankingCandidatePriorityOverCurrentExactReply(input, entryPoint, candidate); const currentClarificationProtectsCurrentReply = currentClarificationRequiredAddressReply && !discoveryClarificationCandidatePriority && - !groundedValueFlowCandidatePriority; + !groundedValueFlowCandidatePriority && + !groundedMovementCandidatePriority && + !groundedValueFlowRankingCandidatePriority; + const alignedFactualAddressReplyProtectsCurrent = alignedFactualAddressReply && !groundedValueFlowRankingCandidatePriority; + const matchedFactualAddressContinuationTargetProtectsCurrent = matchedFactualAddressContinuationTarget && !groundedValueFlowRankingCandidatePriority; + const fullConfirmedFactualAddressReplyProtectsCurrent = fullConfirmedFactualAddressReply && !groundedValueFlowRankingCandidatePriority; + const exactMatchedFactualAddressReplyProtectsCurrent = exactMatchedFactualAddressReply && !groundedValueFlowRankingCandidatePriority; const exactBankOperationsProtectsCurrent = exactBankOperationsAddressReply && !semanticConflictWithDiscoveryTurnMeaning && !valueFlowActionConflictWithDiscoveryTurnMeaning; @@ -849,6 +986,12 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { if (groundedValueFlowCandidatePriority) { pushReason(reasonCodes, "mcp_discovery_response_policy_grounded_value_flow_candidate_priority_over_current_clarification"); } + if (groundedMovementCandidatePriority) { + pushReason(reasonCodes, "mcp_discovery_response_policy_grounded_movement_candidate_priority_over_inventory_item_clarification"); + } + if (groundedValueFlowRankingCandidatePriority) { + pushReason(reasonCodes, "mcp_discovery_response_policy_grounded_value_flow_ranking_candidate_priority_over_current_exact_reply"); + } if (openScopeValueFlowDiscoveryPriority) { pushReason(reasonCodes, "mcp_discovery_response_policy_open_scope_value_flow_candidate_priority"); } @@ -911,11 +1054,11 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { } const canApply = Boolean(entryPoint) && (unsupportedBoundary || discoveryReadyChatCandidate || discoveryReadyDeepCandidate || discoveryReadyAddressCandidate) && - !alignedFactualAddressReply && - !matchedFactualAddressContinuationTarget && + !alignedFactualAddressReplyProtectsCurrent && + !matchedFactualAddressContinuationTargetProtectsCurrent && !matchedFactualSuggestedIntentPivotTarget && - !fullConfirmedFactualAddressReply && - !exactMatchedFactualAddressReply && + !fullConfirmedFactualAddressReplyProtectsCurrent && + !exactMatchedFactualAddressReplyProtectsCurrent && !runtimeAdjustedExactReply && !runtimeMatchedExactReply && !staleMetadataDiscoveryFallbackAgainstExactAddressReply && diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 110e05e..d85706d 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -541,6 +541,7 @@ function collectFollowupDiscoverySeed(followupContext) { const loopAskedDomainFamily = toNonEmptyString(followupContext?.previous_discovery_loop_asked_domain_family); const loopAskedActionFamily = toNonEmptyString(followupContext?.previous_discovery_loop_asked_action_family); const loopUnsupportedFamily = toNonEmptyString(followupContext?.previous_discovery_loop_unsupported_family); + const loopExplicitDateScope = toNonEmptyString(followupContext?.previous_discovery_loop_explicit_date_scope); const loopMetadataScopeHint = toNonEmptyString(followupContext?.previous_discovery_loop_metadata_scope_hint); const loopSubjectResolutionOptional = followupContext?.previous_discovery_loop_subject_resolution_optional === true; const previousIntent = toNonEmptyString(followupContext?.target_intent) ?? toNonEmptyString(followupContext?.previous_intent); @@ -592,7 +593,7 @@ function collectFollowupDiscoverySeed(followupContext) { const loopProvidedAllTimeScope = loopProvidedAxes.includes("all_time_scope"); const dateScope = loopProvidedAllTimeScope ? "all_time_scope" - : collectDateScopeFromFilters(previousFilters) ?? collectDateScopeFromFilters(rootFilters); + : loopExplicitDateScope ?? collectDateScopeFromFilters(previousFilters) ?? collectDateScopeFromFilters(rootFilters); return { pilotScope: effectivePilotScope, domain: mapped.domain, diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index f670a5d..8119dfd 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -1011,6 +1011,7 @@ function createAssistantTransitionPolicy(deps) { const sourceDiscoveryLoopAskedDomainFamily = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopAskedDomainFamily)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryLoopAskedActionFamily = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopAskedActionFamily)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryLoopUnsupportedFamily = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopUnsupportedFamily)(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryLoopExplicitDateScope = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopExplicitDateScope)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryLoopMetadataScopeHint = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopMetadataScopeHint)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryLoopSubjectResolutionOptional = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopSubjectResolutionOptional)(carryoverSourceDebug); const sourceDiscoveryRankingNeed = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryRankingNeed)(carryoverSourceDebug, deps.toNonEmptyString); @@ -1346,6 +1347,7 @@ function createAssistantTransitionPolicy(deps) { previous_discovery_loop_asked_domain_family: sourceDiscoveryLoopAskedDomainFamily ?? undefined, previous_discovery_loop_asked_action_family: sourceDiscoveryLoopAskedActionFamily ?? undefined, previous_discovery_loop_unsupported_family: sourceDiscoveryLoopUnsupportedFamily ?? undefined, + previous_discovery_loop_explicit_date_scope: sourceDiscoveryLoopExplicitDateScope ?? undefined, previous_discovery_loop_metadata_scope_hint: sourceDiscoveryLoopMetadataScopeHint ?? undefined, previous_discovery_loop_subject_resolution_optional: sourceDiscoveryLoopSubjectResolutionOptional || undefined, previous_discovery_ranking_need: sourceDiscoveryRankingNeed ?? undefined, diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index d34870c..886cf27 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -330,6 +330,17 @@ export function readAssistantMcpDiscoveryLoopUnsupportedFamily( return toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.unsupported_but_understood_family); } +export function readAssistantMcpDiscoveryLoopExplicitDateScope( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string | null { + return ( + toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.explicit_date_scope) ?? + toNonEmptyString(readAssistantMcpDiscoveryTurnMeaning(debug)?.explicit_date_scope) ?? + toNonEmptyString(readAssistantMcpDiscoveryDataNeedGraph(debug)?.explicit_date_scope) + ); +} + export function readAssistantMcpDiscoveryLoopMetadataScopeHint( debug: Record | null, toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index 6996cb6..b7d659c 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -358,6 +358,36 @@ function isOpenScopeValueFlowRanking( return businessFactFamily === "value_flow" && subjectCandidates.length === 0 && Boolean(toNonEmptyString(graph?.ranking_need)); } +function readDiscoverySelectedChainId( + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): string | null { + const bridge = toRecordObject(entryPoint?.bridge); + const pilot = toRecordObject(bridge?.pilot); + const routeCandidate = toRecordObject(bridge?.route_candidate); + return ( + toNonEmptyString(routeCandidate?.selected_chain_id) ?? + toNonEmptyString(bridge?.selected_chain_id) ?? + toNonEmptyString(pilot?.selected_chain_id) + ); +} + +function isValueFlowRankingDiscoveryTurn( + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + const graph = readDiscoveryDataNeedGraph(entryPoint); + const bridge = toRecordObject(entryPoint?.bridge); + const routeCandidate = toRecordObject(bridge?.route_candidate); + const selectedChainId = readDiscoverySelectedChainId(entryPoint); + const graphReasonCodes = readStringArray(graph?.reason_codes); + return Boolean( + toNonEmptyString(graph?.business_fact_family) === "value_flow" && + (selectedChainId === "value_flow_ranking" || + toNonEmptyString(routeCandidate?.nearest_catalog_chain_template) === "value_flow_ranking" || + toNonEmptyString(graph?.ranking_need) === "top_desc" || + graphReasonCodes.includes("data_need_graph_ranking_top_desc")) + ); +} + function readTruthAnswerShape(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): Record | null { const directShape = toRecordObject(input.addressRuntimeMeta?.answer_shape_contract); if (directShape) { @@ -433,6 +463,69 @@ function hasDiscoveryClarificationCandidatePriorityOverCurrentClarification( return asksForMovements && staleInventoryItemClarification; } +function hasGroundedMovementCandidatePriorityOverCurrentInventoryItemClarification( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null, + candidate: AssistantMcpDiscoveryResponseCandidateContract +): boolean { + if (!hasCurrentClarificationRequiredAddressReply(input)) { + return false; + } + if (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 bridge = toRecordObject(entryPoint?.bridge); + const pilot = toRecordObject(bridge?.pilot); + const handoff = toRecordObject(bridge?.execution_handoff); + const selectedChainId = readDiscoverySelectedChainId(entryPoint); + const discoveryFamily = toNonEmptyString(graph?.business_fact_family); + const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); + const askedAction = + toNonEmptyString(turnMeaning?.asked_action_family) ?? toNonEmptyString(graph?.action_family); + const asksForMovements = Boolean( + selectedChainId === "movement_evidence" || + discoveryFamily === "movement_evidence" || + askedDomain === "movements" || + askedAction === "list_movements" + ); + + 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_")) + ); + const mcpExecutionPerformed = handoff?.mcp_execution_performed === true || pilot?.mcp_execution_performed === true; + const boundedMovementAnswerAllowed = + bridge?.user_facing_response_allowed === true && bridge?.business_fact_answer_allowed === true; + + return asksForMovements && staleInventoryItemClarification && mcpExecutionPerformed && boundedMovementAnswerAllowed; +} + function hasGroundedValueFlowCandidatePriorityOverCurrentClarification( input: ApplyAssistantMcpDiscoveryResponsePolicyInput, entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null, @@ -455,11 +548,7 @@ function hasGroundedValueFlowCandidatePriorityOverCurrentClarification( const bridge = toRecordObject(entryPoint?.bridge); const pilot = toRecordObject(bridge?.pilot); const handoff = toRecordObject(bridge?.execution_handoff); - const routeCandidate = toRecordObject(bridge?.route_candidate); - const selectedChainId = - toNonEmptyString(routeCandidate?.selected_chain_id) ?? - toNonEmptyString(bridge?.selected_chain_id) ?? - toNonEmptyString(pilot?.selected_chain_id); + const selectedChainId = readDiscoverySelectedChainId(entryPoint); const pilotScope = toNonEmptyString(pilot?.pilot_scope); const actionFamily = toNonEmptyString(graph?.action_family); const comparisonNeed = toNonEmptyString(graph?.comparison_need); @@ -477,6 +566,91 @@ function hasGroundedValueFlowCandidatePriorityOverCurrentClarification( return valueFlowComparison && mcpExecutionPerformed && guardedBusinessAnswerAllowed; } +function hasCounterpartyRankingQuestionSignal( + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + const graph = readDiscoveryDataNeedGraph(entryPoint); + const turnMeaning = readDiscoveryTurnMeaning(entryPoint); + const rawMessage = toNonEmptyString(turnMeaning?.raw_message) ?? ""; + const effectiveMessage = toNonEmptyString(turnMeaning?.effective_message) ?? ""; + const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); + const askedAction = toNonEmptyString(turnMeaning?.asked_action_family); + const text = `${rawMessage}\n${effectiveMessage}`.toLowerCase(); + return Boolean( + isValueFlowRankingDiscoveryTurn(entryPoint) && + (askedDomain === "counterparty_value" || askedDomain === "counterparty") && + (askedAction === "turnover" || askedAction === "counterparty_value_or_turnover") && + (/\bкто\b/u.test(text) || + text.includes("контрагент") || + text.includes("клиент") || + text.includes("покупател") || + text.includes("заказчик") || + text.includes("принес") || + text.includes("принёс") || + text.includes("больше всего") || + toNonEmptyString(graph?.ranking_need) === "top_desc") + ); +} + +function hasWrongPeriodRankingCurrentReply(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): boolean { + const currentReply = String(input.currentReply ?? "").toLowerCase(); + return ( + currentReply.includes("самый доходный год") || + currentReply.includes("топ-1 лет") || + currentReply.includes("топ-5 лет") || + currentReply.includes("топ-10 лет") || + /топ-\d+\s+лет/u.test(currentReply) || + currentReply.includes("лет по сумме поступлений") || + currentReply.includes("год по подтвержденным поступлениям") + ); +} + +function hasConfirmedValueFlowRankingEvidence( + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + const bridge = toRecordObject(entryPoint?.bridge); + const pilot = toRecordObject(bridge?.pilot); + const handoff = toRecordObject(bridge?.execution_handoff); + const evidence = toRecordObject(pilot?.evidence); + const confirmedFacts = readStringArray(evidence?.confirmed_facts); + const mcpExecutionPerformed = handoff?.mcp_execution_performed === true || pilot?.mcp_execution_performed === true; + const businessAnswerAllowed = + bridge?.user_facing_response_allowed === true && bridge?.business_fact_answer_allowed === true; + const confirmedRankingEvidence = Boolean( + toNonEmptyString(evidence?.evidence_status) === "confirmed" || + toNonEmptyString(evidence?.answer_permission) === "confirmed_answer" || + confirmedFacts.some((fact) => /ranked by counterparty|рейтинг.*контрагент/iu.test(fact)) + ); + return mcpExecutionPerformed && businessAnswerAllowed && confirmedRankingEvidence; +} + +function hasGroundedValueFlowRankingCandidatePriorityOverCurrentExactReply( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null, + candidate: AssistantMcpDiscoveryResponseCandidateContract +): boolean { + if (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; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + return Boolean( + hasCounterpartyRankingQuestionSignal(entryPoint) && + hasWrongPeriodRankingCurrentReply(input) && + hasConfirmedValueFlowRankingEvidence(entryPoint) + ); +} + function hasEffectivelyFactualAddressReply(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): boolean { if (toNonEmptyString(input.currentReplyType) === "factual") { return true; @@ -1092,10 +1266,24 @@ export function applyAssistantMcpDiscoveryResponsePolicy( entryPoint, candidate ); + const groundedMovementCandidatePriority = + hasGroundedMovementCandidatePriorityOverCurrentInventoryItemClarification(input, entryPoint, candidate); + const groundedValueFlowRankingCandidatePriority = + hasGroundedValueFlowRankingCandidatePriorityOverCurrentExactReply(input, entryPoint, candidate); const currentClarificationProtectsCurrentReply = currentClarificationRequiredAddressReply && !discoveryClarificationCandidatePriority && - !groundedValueFlowCandidatePriority; + !groundedValueFlowCandidatePriority && + !groundedMovementCandidatePriority && + !groundedValueFlowRankingCandidatePriority; + const alignedFactualAddressReplyProtectsCurrent = + alignedFactualAddressReply && !groundedValueFlowRankingCandidatePriority; + const matchedFactualAddressContinuationTargetProtectsCurrent = + matchedFactualAddressContinuationTarget && !groundedValueFlowRankingCandidatePriority; + const fullConfirmedFactualAddressReplyProtectsCurrent = + fullConfirmedFactualAddressReply && !groundedValueFlowRankingCandidatePriority; + const exactMatchedFactualAddressReplyProtectsCurrent = + exactMatchedFactualAddressReply && !groundedValueFlowRankingCandidatePriority; const exactBankOperationsProtectsCurrent = exactBankOperationsAddressReply && !semanticConflictWithDiscoveryTurnMeaning && @@ -1147,6 +1335,18 @@ export function applyAssistantMcpDiscoveryResponsePolicy( "mcp_discovery_response_policy_grounded_value_flow_candidate_priority_over_current_clarification" ); } + if (groundedMovementCandidatePriority) { + pushReason( + reasonCodes, + "mcp_discovery_response_policy_grounded_movement_candidate_priority_over_inventory_item_clarification" + ); + } + if (groundedValueFlowRankingCandidatePriority) { + pushReason( + reasonCodes, + "mcp_discovery_response_policy_grounded_value_flow_ranking_candidate_priority_over_current_exact_reply" + ); + } if (openScopeValueFlowDiscoveryPriority) { pushReason(reasonCodes, "mcp_discovery_response_policy_open_scope_value_flow_candidate_priority"); } @@ -1229,11 +1429,11 @@ export function applyAssistantMcpDiscoveryResponsePolicy( const canApply = Boolean(entryPoint) && (unsupportedBoundary || discoveryReadyChatCandidate || discoveryReadyDeepCandidate || discoveryReadyAddressCandidate) && - !alignedFactualAddressReply && - !matchedFactualAddressContinuationTarget && + !alignedFactualAddressReplyProtectsCurrent && + !matchedFactualAddressContinuationTargetProtectsCurrent && !matchedFactualSuggestedIntentPivotTarget && - !fullConfirmedFactualAddressReply && - !exactMatchedFactualAddressReply && + !fullConfirmedFactualAddressReplyProtectsCurrent && + !exactMatchedFactualAddressReplyProtectsCurrent && !runtimeAdjustedExactReply && !runtimeMatchedExactReply && !staleMetadataDiscoveryFallbackAgainstExactAddressReply && diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index 1712f91..57602bf 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -699,6 +699,7 @@ function collectFollowupDiscoverySeed(followupContext: Record | const loopAskedDomainFamily = toNonEmptyString(followupContext?.previous_discovery_loop_asked_domain_family); const loopAskedActionFamily = toNonEmptyString(followupContext?.previous_discovery_loop_asked_action_family); const loopUnsupportedFamily = toNonEmptyString(followupContext?.previous_discovery_loop_unsupported_family); + const loopExplicitDateScope = toNonEmptyString(followupContext?.previous_discovery_loop_explicit_date_scope); const loopMetadataScopeHint = toNonEmptyString(followupContext?.previous_discovery_loop_metadata_scope_hint); const loopSubjectResolutionOptional = followupContext?.previous_discovery_loop_subject_resolution_optional === true; @@ -763,7 +764,7 @@ function collectFollowupDiscoverySeed(followupContext: Record | const loopProvidedAllTimeScope = loopProvidedAxes.includes("all_time_scope"); const dateScope = loopProvidedAllTimeScope ? "all_time_scope" - : collectDateScopeFromFilters(previousFilters) ?? collectDateScopeFromFilters(rootFilters); + : loopExplicitDateScope ?? collectDateScopeFromFilters(previousFilters) ?? collectDateScopeFromFilters(rootFilters); return { pilotScope: effectivePilotScope, domain: mapped.domain, diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index 89e468d..d4a4756 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -28,6 +28,7 @@ import { readAssistantMcpDiscoveryLoopAskedDomainFamily, readAssistantMcpDiscoveryLoopAskedActionFamily, readAssistantMcpDiscoveryLoopUnsupportedFamily, + readAssistantMcpDiscoveryLoopExplicitDateScope, readAssistantMcpDiscoveryLoopMetadataScopeHint, readAssistantMcpDiscoveryLoopSubjectResolutionOptional, readAddressDebugTemporalScope, @@ -1388,6 +1389,10 @@ export function createAssistantTransitionPolicy(deps) { carryoverSourceDebug, deps.toNonEmptyString ); + const sourceDiscoveryLoopExplicitDateScope = readAssistantMcpDiscoveryLoopExplicitDateScope( + carryoverSourceDebug, + deps.toNonEmptyString + ); const sourceDiscoveryLoopMetadataScopeHint = readAssistantMcpDiscoveryLoopMetadataScopeHint( carryoverSourceDebug, deps.toNonEmptyString @@ -1859,6 +1864,7 @@ export function createAssistantTransitionPolicy(deps) { previous_discovery_loop_asked_domain_family: sourceDiscoveryLoopAskedDomainFamily ?? undefined, previous_discovery_loop_asked_action_family: sourceDiscoveryLoopAskedActionFamily ?? undefined, previous_discovery_loop_unsupported_family: sourceDiscoveryLoopUnsupportedFamily ?? undefined, + previous_discovery_loop_explicit_date_scope: sourceDiscoveryLoopExplicitDateScope ?? undefined, previous_discovery_loop_metadata_scope_hint: sourceDiscoveryLoopMetadataScopeHint ?? undefined, previous_discovery_loop_subject_resolution_optional: sourceDiscoveryLoopSubjectResolutionOptional || undefined, diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index 742b123..f1cc03e 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -130,6 +130,76 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).toContain("mcp_discovery_response_policy_keep_current_clarification_required_reply"); }); + it("applies a grounded movement candidate over a stale inventory item clarification", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "Need an item before I can continue.", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "partial_coverage", + addressRuntimeMeta: { + truth_mode: "clarification_required", + answer_shape: "clarification_required", + answer_shape_contract: { + answer_shape: "clarification_required", + reply_type: "partial_coverage", + capability_contract_id: "inventory_inventory_purchase_documents_for_item" + }, + detected_intent: "inventory_purchase_documents_for_item", + selected_recipe: "address_inventory_purchase_documents_for_item_v1", + capability_id: "inventory_inventory_purchase_documents_for_item", + missing_required_filters: ["item"], + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "movements", + asked_action_family: "list_movements", + explicit_date_scope: "2020" + }, + data_need_graph: { + business_fact_family: "movement_evidence", + action_family: "list_movements" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + route_candidate: { + selected_chain_id: "movement_evidence" + }, + pilot: { + selected_chain_id: "movement_evidence", + mcp_execution_performed: true + }, + execution_handoff: { + mcp_execution_performed: true, + can_use_guarded_response: false + }, + answer_draft: { + answer_mode: "bounded_inference_only", + headline: "Movement evidence for Group SVK in 2020 is bounded.", + confirmed_lines: ["No confirmed movement rows were found in the checked 2020 window."], + inference_lines: [], + unknown_lines: [], + limitation_lines: ["Full movement coverage outside the checked window is not confirmed."], + next_step_line: null + } + } + }) + } + }); + + expect(result.applied).toBe(true); + expect(result.reply_text).toContain("Movement evidence for Group SVK in 2020 is bounded."); + expect(result.reply_text).not.toContain("Need an item"); + expect(result.reason_codes).toContain( + "mcp_discovery_response_policy_grounded_movement_candidate_priority_over_inventory_item_clarification" + ); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_candidate_applied"); + }); + it("keeps the current reply when the turn is not an unsupported boundary", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "regular chat", @@ -1252,6 +1322,107 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).toContain("mcp_discovery_response_policy_open_scope_value_flow_candidate_priority"); }); + it("overrides a year-ranking exact reply when the user asked who brought the most money", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: + "Самый доходный год по подтвержденным поступлениям: 2020 (47.628.853,03 ₽ по 44 операциям).\nТоп-1 лет по сумме поступлений:\n1. 2020 | сумма: 47.628.853,03 ₽ | операций: 44 | контрагентов: 13", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "customer_revenue_and_payments", + selected_recipe: "address_customer_revenue_and_payments_v1", + mcp_call_status: "matched_non_empty", + truth_mode: "confirmed", + capability_binding_status: "bound", + capability_binding_violations: [], + dialogContinuationContract: { + target_intent: "customer_revenue_and_payments" + }, + truth_gate_contract_status: "full_confirmed", + assistant_truth_answer_policy_v1: { + truth_gate: { + coverage_status: "full", + grounding_status: "grounded", + source_truth_gate_status: "full_confirmed" + } + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + data_need_graph: { + business_fact_family: "value_flow", + subject_candidates: ["Группа СВК"], + ranking_need: "top_desc", + reason_codes: ["data_need_graph_built", "data_need_graph_ranking_top_desc"] + }, + turn_meaning_ref: { + raw_message: "И кто больше всего принес денег этой организации в 2020 году?", + effective_message: "Определить, какой контрагент принёс наибольшую выручку организации в 2020 году", + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["Группа СВК"], + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + route_candidate: { + selected_chain_id: "value_flow_ranking", + nearest_catalog_chain_template: "value_flow_ranking" + }, + execution_handoff: { + selected_chain_id: "value_flow_ranking", + mcp_execution_performed: true, + can_use_guarded_response: false, + must_keep_internal_mechanics_hidden: true + }, + pilot: { + pilot_scope: "counterparty_value_flow_query_movements_v1", + mcp_execution_performed: true, + evidence: { + evidence_status: "confirmed", + coverage_status: "full", + answer_permission: "confirmed_answer", + confirmed_facts: [ + "1C incoming value-flow rows were ranked by counterparty for the checked scope; leader=Группа СВК, rows_with_amount=13" + ] + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "По данным 1С можно построить ограниченный рейтинг по контрагентам.", + confirmed_lines: [ + "В проверенных входящих поступлениях найден один контрагент Группа СВК по организации ООО Альтернатива Плюс за период 2020: 12 093 465 руб. по 13 строкам с суммой." + ], + inference_lines: [ + "Рейтинг по контрагентам по организации ООО Альтернатива Плюс за период 2020 рассчитан только по подтвержденным строкам 1С." + ], + unknown_lines: ["Полный рейтинг контрагентов вне периода 2020 этим поиском не подтвержден."], + limitation_lines: [], + next_step_line: null + } + } + }) + } + }); + + expect(result.applied).toBe(true); + expect(result.decision).toBe("apply_candidate"); + expect(result.reply_text).toContain("Группа СВК"); + expect(result.reply_text).toContain("12 093 465 руб."); + expect(result.reply_text).not.toContain("Самый доходный год"); + expect(result.reply_text).not.toContain("Топ-1 лет"); + expect(result.reason_codes).toContain( + "mcp_discovery_response_policy_grounded_value_flow_ranking_candidate_priority_over_current_exact_reply" + ); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_candidate_applied"); + }); + it("keeps full-confirmed factual address replies even when discovery has a guarded candidate", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "ООО Ромашка | сумма: 128000 | операций: 3", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index 70debca..ae372ec 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -838,6 +838,54 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); }); + it("prefers semantic loop date scope over technical current-date filters for net value-flow follow-ups", () => { + const counterpartyName = "\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a"; + const organizationName = + "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "\u0430 \u043a\u0430\u043a\u043e\u0435 \u043d\u0435\u0442\u0442\u043e?", + assistantTurnMeaning: { + asked_domain_family: "counterparty", + asked_action_family: "turnover", + explicit_intent_candidate: "customer_revenue_and_payments" + }, + followupContext: { + previous_discovery_pilot_scope: "counterparty_supplier_payout_query_movements_v1", + previous_discovery_loop_status: "ready_for_next_hop", + previous_discovery_loop_selected_chain_id: "value_flow", + previous_discovery_loop_asked_domain_family: "counterparty_value", + previous_discovery_loop_asked_action_family: "payout", + previous_discovery_loop_explicit_date_scope: "2020", + previous_discovery_loop_metadata_scope_hint: counterpartyName, + previous_filters: { + counterparty: counterpartyName, + organization: organizationName, + period_from: "2020-01-01", + period_to: "2026-06-03" + }, + previous_anchor_type: "counterparty", + previous_anchor_value: counterpartyName + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("counterparty value-flow evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "net_value_flow", + explicit_entity_candidates: [counterpartyName], + explicit_organization_scope: organizationName, + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting", + stale_replay_forbidden: true + }); + expect(result.data_need_graph?.comparison_need).toBe("incoming_vs_outgoing"); + expect(result.reason_codes).toContain("mcp_discovery_grounded_value_flow_followup"); + expect(result.reason_codes).toContain("mcp_discovery_date_scope_from_followup_context"); + expect(result.reason_codes).toContain("mcp_discovery_bidirectional_value_flow_signal_detected"); + }); + it("prefers grounded discovery metadata scope over stale conflicting counterparty in a short net follow-up", () => { const result = buildAssistantMcpDiscoveryTurnInput({ userMessage: "Р° какое нетто?", diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index bdcbd80..6cad380 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -1903,6 +1903,7 @@ describe("assistantTransitionPolicy", () => { expect(carryover?.followupContext?.previous_discovery_loop_unsupported_family).toBe( "counterparty_value_or_turnover" ); + expect(carryover?.followupContext?.previous_discovery_loop_explicit_date_scope).toBe("2020"); }); it("treats a plain organization reply as continuation of a pending route-candidate organization scope", () => { @@ -1963,6 +1964,7 @@ describe("assistantTransitionPolicy", () => { expect(carryover?.followupContext?.previous_discovery_loop_status).toBe("awaiting_clarification"); expect(carryover?.followupContext?.previous_discovery_loop_selected_chain_id).toBe("value_flow_ranking"); expect(carryover?.followupContext?.previous_discovery_loop_pending_axes).toEqual(["organization"]); + expect(carryover?.followupContext?.previous_discovery_loop_explicit_date_scope).toBe("2020"); expect(carryover?.followupContext?.target_intent).toBe("customer_revenue_and_payments"); }); diff --git a/llm_normalizer/data/autorun_generators/history.json b/llm_normalizer/data/autorun_generators/history.json index 3e79073..2f90afb 100644 --- a/llm_normalizer/data/autorun_generators/history.json +++ b/llm_normalizer/data/autorun_generators/history.json @@ -1,7 +1,7 @@ [ { - "generation_id": "gen-ag06031252-8c5319", - "created_at": "2026-06-03T12:52:51+00:00", + "generation_id": "gen-ag06031328-3e1dd1", + "created_at": "2026-06-03T13:28:09+00:00", "mode": "saved_user_sessions", "title": "AGENT | Phase 83 mixed planner-brain replay for catalog alignment, pivots, and legacy continuity", "count": 20, @@ -29,7 +29,7 @@ "а чем капибара отличается от утки?" ], "generated_by": "codex_agent", - "saved_case_set_file": "assistant_autogen_saved_user_sessions_20260603125251_gen-ag06031252-8c5319.json", + "saved_case_set_file": "assistant_autogen_saved_user_sessions_20260603132809_gen-ag06031328-3e1dd1.json", "context": { "llm_provider": null, "model": null, @@ -39,7 +39,7 @@ "autogen_personality_id": null, "autogen_personality_prompt": null, "source_session_id": null, - "saved_session_file": "assistant_saved_session_20260603125251_gen-ag06031252-8c5319.json", + "saved_session_file": "assistant_saved_session_20260603132809_gen-ag06031328-3e1dd1.json", "saved_case_set_kind": "agent_semantic_scenario", "agent_run": true, "agent_focus": "Mixed AGENT replay for Planner Autonomy Consolidation. The pack interleaves selected-counterparty catalog-alignment probes, open-organization money flow, ranking, broad-evaluation continuity, metadata drilldown, and off-domain living-chat safety.", @@ -85,7 +85,7 @@ "year_switch" ], "validation_status": "accepted_live_replay", - "validated_run_dir": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live6", + "validated_run_dir": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live9", "saved_after_validated_replay": true } }, diff --git a/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260603125251_gen-ag06031252-8c5319.json b/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260603132809_gen-ag06031328-3e1dd1.json similarity index 89% rename from llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260603125251_gen-ag06031252-8c5319.json rename to llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260603132809_gen-ag06031328-3e1dd1.json index e6ca9d1..6572d8e 100644 --- a/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260603125251_gen-ag06031252-8c5319.json +++ b/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260603132809_gen-ag06031328-3e1dd1.json @@ -1,6 +1,6 @@ { - "saved_at": "2026-06-03T12:52:51+00:00", - "generation_id": "gen-ag06031252-8c5319", + "saved_at": "2026-06-03T13:28:09+00:00", + "generation_id": "gen-ag06031328-3e1dd1", "mode": "saved_user_sessions", "title": "AGENT | Phase 83 mixed planner-brain replay for catalog alignment, pivots, and legacy continuity", "agent_run": true, @@ -73,12 +73,12 @@ "year_switch" ], "validation_status": "accepted_live_replay", - "validated_run_dir": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live6", + "validated_run_dir": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live9", "saved_after_validated_replay": true, "save_gate": { "schema_version": "agent_semantic_save_gate_v1", "validation_status": "accepted_live_replay", - "validated_run_dir": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live6", + "validated_run_dir": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live9", "final_status": "accepted", "review_overall_status": "pass", "business_overall_status": "pass", @@ -89,9 +89,9 @@ "steps_with_business_warnings": 0, "acceptance_gate_passed": true, "effective_runtime": { - "manifest_path": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live6\\effective_runtime.json", + "manifest_path": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live9\\effective_runtime.json", "runner": "domain_truth_harness.run-live", - "git_sha": "f24cddc492138745aa896f52d1a6ebee8bab08ab", + "git_sha": "5ed25bad8115842e835b08b35a0edf623a757d9b", "backend_url": "http://127.0.0.1:8787", "mcp_proxy_url": "http://127.0.0.1:6003", "llm_provider": "local", @@ -115,7 +115,7 @@ "message_id": "agent-user-001", "role": "user", "text": "привет, ты на связи?", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -124,7 +124,7 @@ "message_id": "agent-user-002", "role": "user", "text": "найди в 1С контрагента СВК", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -133,7 +133,7 @@ "message_id": "agent-user-003", "role": "user", "text": "сколько получили по нему за 2020 год", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -142,7 +142,7 @@ "message_id": "agent-user-004", "role": "user", "text": "а теперь сколько заплатили?", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -151,7 +151,7 @@ "message_id": "agent-user-005", "role": "user", "text": "а какое нетто?", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -160,7 +160,7 @@ "message_id": "agent-user-006", "role": "user", "text": "а по документам?", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -169,7 +169,7 @@ "message_id": "agent-user-007", "role": "user", "text": "а по движениям?", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -178,7 +178,7 @@ "message_id": "agent-user-008", "role": "user", "text": "Хочу быстрый денежный срез по одной организации без привязки к контрагенту. Сколько вообще входящих денег было за 2020 год?", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -187,7 +187,7 @@ "message_id": "agent-user-009", "role": "user", "text": "По ООО Альтернатива Плюс.", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -196,7 +196,7 @@ "message_id": "agent-user-010", "role": "user", "text": "Понял, тогда за все время.", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -205,7 +205,7 @@ "message_id": "agent-user-011", "role": "user", "text": "Хорошо. А что по ООО Альтернатива Плюс больше в 2020 году: входящие или исходящие деньги?", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -214,7 +214,7 @@ "message_id": "agent-user-012", "role": "user", "text": "А что по ООО Альтернатива Плюс больше уже за 2021 год: входящие или исходящие деньги?", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -223,7 +223,7 @@ "message_id": "agent-user-013", "role": "user", "text": "И кто больше всего принес денег этой организации в 2020 году?", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -232,7 +232,7 @@ "message_id": "agent-user-014", "role": "user", "text": "А в 2021 году?", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -241,7 +241,7 @@ "message_id": "agent-user-015", "role": "user", "text": "а по Альтернативе Плюс сколько лет активности в базе 1С?", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -250,7 +250,7 @@ "message_id": "agent-user-016", "role": "user", "text": "Как ты оценишь деятельность компании?", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -259,7 +259,7 @@ "message_id": "agent-user-017", "role": "user", "text": "какое нетто по деньгам с Группа СВК за 2020 год: сколько получили и сколько заплатили?", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -268,7 +268,7 @@ "message_id": "agent-user-018", "role": "user", "text": "какие справочники 1С есть по контрагентам?", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -277,7 +277,7 @@ "message_id": "agent-user-019", "role": "user", "text": "давай дальше", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -286,7 +286,7 @@ "message_id": "agent-user-020", "role": "user", "text": "а чем капибара отличается от утки?", - "created_at": "2026-06-03T12:52:51+00:00", + "created_at": "2026-06-03T13:28:09+00:00", "reply_type": null, "trace_id": null, "debug": null @@ -340,12 +340,12 @@ "year_switch" ], "validation_status": "accepted_live_replay", - "validated_run_dir": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live6", + "validated_run_dir": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live9", "saved_after_validated_replay": true, "save_gate": { "schema_version": "agent_semantic_save_gate_v1", "validation_status": "accepted_live_replay", - "validated_run_dir": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live6", + "validated_run_dir": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live9", "final_status": "accepted", "review_overall_status": "pass", "business_overall_status": "pass", @@ -356,9 +356,9 @@ "steps_with_business_warnings": 0, "acceptance_gate_passed": true, "effective_runtime": { - "manifest_path": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live6\\effective_runtime.json", + "manifest_path": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live9\\effective_runtime.json", "runner": "domain_truth_harness.run-live", - "git_sha": "f24cddc492138745aa896f52d1a6ebee8bab08ab", + "git_sha": "5ed25bad8115842e835b08b35a0edf623a757d9b", "backend_url": "http://127.0.0.1:8787", "mcp_proxy_url": "http://127.0.0.1:6003", "llm_provider": "local", diff --git a/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260603125251_gen-ag06031252-8c5319.json b/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260603132809_gen-ag06031328-3e1dd1.json similarity index 95% rename from llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260603125251_gen-ag06031252-8c5319.json rename to llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260603132809_gen-ag06031328-3e1dd1.json index 4701bc0..b81e475 100644 --- a/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260603125251_gen-ag06031252-8c5319.json +++ b/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260603132809_gen-ag06031328-3e1dd1.json @@ -1,9 +1,9 @@ { - "suite_id": "assistant_saved_session_gen-ag06031252-8c5319", + "suite_id": "assistant_saved_session_gen-ag06031328-3e1dd1", "suite_version": "0.1.0", "schema_version": "assistant_saved_session_suite_v0_1", - "generated_at": "2026-06-03T12:52:51+00:00", - "generation_id": "gen-ag06031252-8c5319", + "generated_at": "2026-06-03T13:28:09+00:00", + "generation_id": "gen-ag06031328-3e1dd1", "mode": "saved_user_sessions", "title": "AGENT | Phase 83 mixed planner-brain replay for catalog alignment, pivots, and legacy continuity", "domain": "planner_autonomy_consolidation",