diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js index f88e6f5..8123487 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -5,6 +5,13 @@ exports.readAssistantMcpDiscoveryEntityAmbiguityCandidates = readAssistantMcpDis exports.readAssistantMcpDiscoveryEntityCandidates = readAssistantMcpDiscoveryEntityCandidates; exports.readAssistantMcpDiscoveryPilotScope = readAssistantMcpDiscoveryPilotScope; exports.readAssistantMcpDiscoveryRankingNeed = readAssistantMcpDiscoveryRankingNeed; +exports.readAssistantMcpDiscoveryLoopStatus = readAssistantMcpDiscoveryLoopStatus; +exports.readAssistantMcpDiscoveryLoopSelectedChainId = readAssistantMcpDiscoveryLoopSelectedChainId; +exports.readAssistantMcpDiscoveryLoopPendingAxes = readAssistantMcpDiscoveryLoopPendingAxes; +exports.readAssistantMcpDiscoveryLoopProvidedAxes = readAssistantMcpDiscoveryLoopProvidedAxes; +exports.readAssistantMcpDiscoveryLoopAskedDomainFamily = readAssistantMcpDiscoveryLoopAskedDomainFamily; +exports.readAssistantMcpDiscoveryLoopAskedActionFamily = readAssistantMcpDiscoveryLoopAskedActionFamily; +exports.readAssistantMcpDiscoveryLoopUnsupportedFamily = readAssistantMcpDiscoveryLoopUnsupportedFamily; exports.readAssistantMcpDiscoveryMetadataRouteFamily = readAssistantMcpDiscoveryMetadataRouteFamily; exports.readAssistantMcpDiscoveryMetadataRouteFamilySelectionBasis = readAssistantMcpDiscoveryMetadataRouteFamilySelectionBasis; exports.readAssistantMcpDiscoveryMetadataSelectedEntitySet = readAssistantMcpDiscoveryMetadataSelectedEntitySet; @@ -98,6 +105,9 @@ function readAssistantMcpDiscoveryActionFamily(debug, toNonEmptyString = fallbac function readAssistantMcpDiscoveryBridge(debug) { return toRecordObject(readAssistantMcpDiscoveryEntry(debug)?.bridge); } +function readAssistantMcpDiscoveryLoopState(debug) { + return toRecordObject(readAssistantMcpDiscoveryBridge(debug)?.loop_state); +} function readAssistantMcpDiscoveryDerivedMetadataSurface(debug) { const bridge = readAssistantMcpDiscoveryBridge(debug); const pilot = toRecordObject(bridge?.pilot); @@ -152,7 +162,37 @@ function readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString = fallbackT return toNonEmptyString(pilot?.pilot_scope); } function readAssistantMcpDiscoveryRankingNeed(debug, toNonEmptyString = fallbackToNonEmptyString) { - return toNonEmptyString(readAssistantMcpDiscoveryDataNeedGraph(debug)?.ranking_need); + return (toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.ranking_need) ?? + toNonEmptyString(readAssistantMcpDiscoveryDataNeedGraph(debug)?.ranking_need)); +} +function readAssistantMcpDiscoveryLoopStatus(debug, toNonEmptyString = fallbackToNonEmptyString) { + return toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.loop_status); +} +function readAssistantMcpDiscoveryLoopSelectedChainId(debug, toNonEmptyString = fallbackToNonEmptyString) { + return toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.selected_chain_id); +} +function readAssistantMcpDiscoveryLoopPendingAxes(debug, toNonEmptyString = fallbackToNonEmptyString) { + const values = readAssistantMcpDiscoveryLoopState(debug)?.pending_axes; + if (!Array.isArray(values)) { + return []; + } + return values.map((item) => toNonEmptyString(item)).filter((item) => Boolean(item)); +} +function readAssistantMcpDiscoveryLoopProvidedAxes(debug, toNonEmptyString = fallbackToNonEmptyString) { + const values = readAssistantMcpDiscoveryLoopState(debug)?.provided_axes; + if (!Array.isArray(values)) { + return []; + } + return values.map((item) => toNonEmptyString(item)).filter((item) => Boolean(item)); +} +function readAssistantMcpDiscoveryLoopAskedDomainFamily(debug, toNonEmptyString = fallbackToNonEmptyString) { + return toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.asked_domain_family); +} +function readAssistantMcpDiscoveryLoopAskedActionFamily(debug, toNonEmptyString = fallbackToNonEmptyString) { + return toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.asked_action_family); +} +function readAssistantMcpDiscoveryLoopUnsupportedFamily(debug, toNonEmptyString = fallbackToNonEmptyString) { + return toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.unsupported_but_understood_family); } function readAssistantMcpDiscoveryMetadataRouteFamily(debug, toNonEmptyString = fallbackToNonEmptyString) { return toNonEmptyString(readAssistantMcpDiscoveryDerivedMetadataSurface(debug)?.downstream_route_family); diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js index 164f41e..3339b45 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js @@ -1,11 +1,12 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION = void 0; +exports.ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION = exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION = void 0; exports.runAssistantMcpDiscoveryRuntimeBridge = runAssistantMcpDiscoveryRuntimeBridge; const assistantMcpDiscoveryAnswerAdapter_1 = require("./assistantMcpDiscoveryAnswerAdapter"); const assistantMcpDiscoveryPilotExecutor_1 = require("./assistantMcpDiscoveryPilotExecutor"); const assistantMcpDiscoveryPlanner_1 = require("./assistantMcpDiscoveryPlanner"); exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION = "assistant_mcp_discovery_runtime_bridge_v1"; +exports.ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION = "assistant_mcp_discovery_loop_state_v1"; function normalizeReasonCode(value) { const normalized = value .trim() @@ -48,6 +49,58 @@ function bridgeStatusFor(pilot, draft) { function businessFactAnswerAllowed(draft) { return draft.answer_mode === "confirmed_with_bounded_inference" || draft.answer_mode === "bounded_inference_only"; } +function loopStatusFor(bridgeStatus) { + if (bridgeStatus === "needs_clarification") { + return "awaiting_clarification"; + } + if (bridgeStatus === "blocked" || bridgeStatus === "unsupported") { + return "blocked"; + } + return "ready_for_next_hop"; +} +function flattenAxes(pilot, source) { + const result = []; + for (const step of pilot.dry_run.execution_steps) { + if (source === "provided_axes") { + for (const axis of step.provided_axes) { + if (axis && !result.includes(axis)) { + result.push(axis); + } + } + continue; + } + for (const option of step.missing_axis_options) { + for (const axis of option) { + if (axis && !result.includes(axis)) { + result.push(axis); + } + } + } + } + return result; +} +function entityCandidatesFromPlanner(planner) { + const values = planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? []; + return uniqueStrings(values); +} +function buildLoopState(planner, pilot, bridgeStatus) { + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryRuntimeBridge", + loop_status: loopStatusFor(bridgeStatus), + selected_chain_id: planner.selected_chain_id, + pilot_scope: pilot.pilot_scope, + asked_domain_family: planner.discovery_plan.turn_meaning_ref?.asked_domain_family ?? null, + asked_action_family: planner.discovery_plan.turn_meaning_ref?.asked_action_family ?? null, + unsupported_but_understood_family: planner.discovery_plan.turn_meaning_ref?.unsupported_but_understood_family ?? null, + ranking_need: planner.data_need_graph?.ranking_need ?? planner.discovery_plan.turn_meaning_ref?.seeded_ranking_need ?? null, + pending_axes: flattenAxes(pilot, "missing_axis_options"), + provided_axes: flattenAxes(pilot, "provided_axes"), + explicit_entity_candidates: entityCandidatesFromPlanner(planner), + explicit_organization_scope: planner.discovery_plan.turn_meaning_ref?.explicit_organization_scope ?? null, + explicit_date_scope: planner.discovery_plan.turn_meaning_ref?.explicit_date_scope ?? null + }; +} async function runAssistantMcpDiscoveryRuntimeBridge(input) { const planner = (0, assistantMcpDiscoveryPlanner_1.planAssistantMcpDiscovery)({ semanticDataNeed: input.semanticDataNeed, @@ -58,9 +111,11 @@ async function runAssistantMcpDiscoveryRuntimeBridge(input) { const pilot = await (0, assistantMcpDiscoveryPilotExecutor_1.executeAssistantMcpDiscoveryPilot)(planner, input.deps); const answerDraft = (0, assistantMcpDiscoveryAnswerAdapter_1.buildAssistantMcpDiscoveryAnswerDraft)(pilot); const bridgeStatus = bridgeStatusFor(pilot, answerDraft); + const loopState = buildLoopState(planner, pilot, bridgeStatus); const reasonCodes = uniqueStrings([...planner.reason_codes, ...pilot.reason_codes, ...answerDraft.reason_codes]); pushReason(reasonCodes, `runtime_bridge_status_${bridgeStatus}`); pushReason(reasonCodes, "runtime_bridge_not_wired_to_hot_assistant_answer"); + pushReason(reasonCodes, `runtime_bridge_loop_state_${loopState.loop_status}`); return { schema_version: exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION, policy_owner: "assistantMcpDiscoveryRuntimeBridge", @@ -69,6 +124,7 @@ async function runAssistantMcpDiscoveryRuntimeBridge(input) { planner, pilot, answer_draft: answerDraft, + loop_state: loopState, user_facing_response_allowed: bridgeStatus !== "blocked", business_fact_answer_allowed: businessFactAnswerAllowed(answerDraft), requires_user_clarification: bridgeStatus === "needs_clarification", diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 941f316..4ff5dac 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -254,14 +254,92 @@ function mapAddressIntentToFollowupMeaning(intent) { unsupported: null }; } +function mapLoopClarificationSeedToFollowupMeaning(input) { + if (input.domain || input.action || input.unsupported) { + return { + domain: input.domain, + action: input.action, + unsupported: input.unsupported + }; + } + if (input.selectedChainId === "metadata_lane_clarification") { + return { + domain: "metadata", + action: "resolve_next_lane", + unsupported: "metadata_lane_choice_clarification" + }; + } + return { + domain: null, + action: null, + unsupported: null + }; +} +function pilotScopeFromLoopClarificationSeed(selectedChainId, action) { + if (!selectedChainId) { + return null; + } + if (selectedChainId === "metadata_inspection" || + selectedChainId === "metadata_lane_clarification" || + selectedChainId === "catalog_drilldown") { + return "metadata_inspection_v1"; + } + if (selectedChainId === "movement_evidence") { + return "counterparty_movement_evidence_query_movements_v1"; + } + if (selectedChainId === "document_evidence") { + return "counterparty_document_evidence_query_documents_v1"; + } + if (selectedChainId === "lifecycle") { + return "counterparty_lifecycle_query_documents_v1"; + } + if (selectedChainId === "entity_resolution") { + return "entity_resolution_search_v1"; + } + if (selectedChainId === "value_flow_comparison") { + return "counterparty_bidirectional_value_flow_query_movements_v1"; + } + if (selectedChainId === "value_flow_ranking" || selectedChainId === "value_flow") { + if (action === "payout") { + return "counterparty_supplier_payout_query_movements_v1"; + } + if (action === "net_value_flow") { + return "counterparty_bidirectional_value_flow_query_movements_v1"; + } + return "counterparty_value_flow_query_movements_v1"; + } + return null; +} function collectFollowupDiscoverySeed(followupContext) { const previousFilters = toRecordObject(followupContext?.previous_filters); const rootFilters = toRecordObject(followupContext?.root_filters); const pilotScope = toNonEmptyString(followupContext?.previous_discovery_pilot_scope); + const loopStatus = toNonEmptyString(followupContext?.previous_discovery_loop_status); + const loopSelectedChainId = toNonEmptyString(followupContext?.previous_discovery_loop_selected_chain_id); + const loopPendingAxes = collectEntityCandidates(followupContext?.previous_discovery_loop_pending_axes); + const loopProvidedAxes = collectEntityCandidates(followupContext?.previous_discovery_loop_provided_axes); + 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 previousIntent = toNonEmptyString(followupContext?.target_intent) ?? toNonEmptyString(followupContext?.previous_intent); - const mapped = mapPilotScopeToFollowupMeaning(pilotScope).domain !== null - ? mapPilotScopeToFollowupMeaning(pilotScope) - : mapAddressIntentToFollowupMeaning(previousIntent); + const loopMapped = loopStatus === "awaiting_clarification" + ? mapLoopClarificationSeedToFollowupMeaning({ + selectedChainId: loopSelectedChainId, + domain: loopAskedDomainFamily, + action: loopAskedActionFamily, + unsupported: loopUnsupportedFamily + }) + : { + domain: null, + action: null, + unsupported: null + }; + const effectivePilotScope = pilotScope ?? pilotScopeFromLoopClarificationSeed(loopSelectedChainId, loopMapped.action); + const mapped = loopMapped.domain !== null || loopMapped.action !== null || loopMapped.unsupported !== null + ? loopMapped + : mapPilotScopeToFollowupMeaning(effectivePilotScope).domain !== null + ? mapPilotScopeToFollowupMeaning(effectivePilotScope) + : mapAddressIntentToFollowupMeaning(previousIntent); const discoveryEntities = collectEntityCandidates(followupContext?.previous_discovery_entity_candidates); const entityResolutionStatus = toNonEmptyString(followupContext?.previous_discovery_entity_resolution_status); const entityResolutionAmbiguityCandidates = collectEntityCandidates(followupContext?.previous_discovery_entity_ambiguity_candidates); @@ -280,10 +358,14 @@ function collectFollowupDiscoverySeed(followupContext) { const dateScope = collectDateScopeFromFilters(previousFilters) ?? collectDateScopeFromFilters(rootFilters); return { - pilotScope, + pilotScope: effectivePilotScope, domain: mapped.domain, action: mapped.action, unsupported: mapped.unsupported, + loopStatus, + loopSelectedChainId, + loopPendingAxes, + loopProvidedAxes, counterparty, discoveryEntity: ambiguityBlocksImplicitGrounding ? null : discoveryEntities[0] ?? null, entityResolutionStatus, @@ -963,6 +1045,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { !predecomposeDateScope && !rawDateScope && followupSeed.dateScope); + const clarificationLoopSeedApplied = Boolean(followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopSelectedChainId); const turnMeaning = { asked_domain_family: lifecycleSignal ? "counterparty_lifecycle" @@ -1151,6 +1234,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (followupDiscoverySeedApplicable) { pushReason(reasonCodes, "mcp_discovery_seeded_from_followup_context"); } + if (clarificationLoopSeedApplied) { + pushReason(reasonCodes, "mcp_discovery_resumed_from_saved_loop_state"); + } if (effectiveMetadataFollowupSeedApplicable) { pushReason(reasonCodes, "mcp_discovery_metadata_seeded_from_followup_context"); } diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index bdebb69..1b766d3 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -508,6 +508,13 @@ function createAssistantTransitionPolicy(deps) { const sourceDiscoveryMetadataAmbiguityEntitySets = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryMetadataAmbiguityEntitySets)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryEntityResolutionStatus = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryEntityResolutionStatus)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryEntityCandidates = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryEntityCandidates)(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryLoopStatus = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopStatus)(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryLoopSelectedChainId = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopSelectedChainId)(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryLoopPendingAxes = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopPendingAxes)(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryLoopProvidedAxes = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopProvidedAxes)(carryoverSourceDebug, deps.toNonEmptyString); + 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 sourceDiscoveryRankingNeed = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryRankingNeed)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryEntityAmbiguityCandidates = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryEntityAmbiguityCandidates)(carryoverSourceDebug, deps.toNonEmptyString); const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); @@ -746,6 +753,13 @@ function createAssistantTransitionPolicy(deps) { previous_discovery_pilot_scope: sourceDiscoveryPilotScope ?? undefined, previous_discovery_entity_resolution_status: sourceDiscoveryEntityResolutionStatus ?? undefined, previous_discovery_entity_candidates: sourceDiscoveryEntityCandidates.length > 0 ? sourceDiscoveryEntityCandidates : undefined, + previous_discovery_loop_status: sourceDiscoveryLoopStatus ?? undefined, + previous_discovery_loop_selected_chain_id: sourceDiscoveryLoopSelectedChainId ?? undefined, + previous_discovery_loop_pending_axes: sourceDiscoveryLoopPendingAxes.length > 0 ? sourceDiscoveryLoopPendingAxes : undefined, + previous_discovery_loop_provided_axes: sourceDiscoveryLoopProvidedAxes.length > 0 ? sourceDiscoveryLoopProvidedAxes : undefined, + 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_ranking_need: sourceDiscoveryRankingNeed ?? undefined, previous_discovery_entity_ambiguity_candidates: sourceDiscoveryEntityAmbiguityCandidates.length > 0 ? sourceDiscoveryEntityAmbiguityCandidates diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index d20f7d8..3ad301d 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -174,6 +174,12 @@ function readAssistantMcpDiscoveryBridge( return toRecordObject(readAssistantMcpDiscoveryEntry(debug)?.bridge); } +function readAssistantMcpDiscoveryLoopState( + debug: Record | null +): Record | null { + return toRecordObject(readAssistantMcpDiscoveryBridge(debug)?.loop_state); +} + function readAssistantMcpDiscoveryDerivedMetadataSurface( debug: Record | null ): Record | null { @@ -260,7 +266,67 @@ export function readAssistantMcpDiscoveryRankingNeed( debug: Record | null, toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString ): string | null { - return toNonEmptyString(readAssistantMcpDiscoveryDataNeedGraph(debug)?.ranking_need); + return ( + toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.ranking_need) ?? + toNonEmptyString(readAssistantMcpDiscoveryDataNeedGraph(debug)?.ranking_need) + ); +} + +export function readAssistantMcpDiscoveryLoopStatus( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string | null { + return toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.loop_status); +} + +export function readAssistantMcpDiscoveryLoopSelectedChainId( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string | null { + return toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.selected_chain_id); +} + +export function readAssistantMcpDiscoveryLoopPendingAxes( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string[] { + const values = readAssistantMcpDiscoveryLoopState(debug)?.pending_axes; + if (!Array.isArray(values)) { + return []; + } + return values.map((item) => toNonEmptyString(item)).filter((item): item is string => Boolean(item)); +} + +export function readAssistantMcpDiscoveryLoopProvidedAxes( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string[] { + const values = readAssistantMcpDiscoveryLoopState(debug)?.provided_axes; + if (!Array.isArray(values)) { + return []; + } + return values.map((item) => toNonEmptyString(item)).filter((item): item is string => Boolean(item)); +} + +export function readAssistantMcpDiscoveryLoopAskedDomainFamily( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string | null { + return toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.asked_domain_family); +} + +export function readAssistantMcpDiscoveryLoopAskedActionFamily( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string | null { + return toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.asked_action_family); +} + +export function readAssistantMcpDiscoveryLoopUnsupportedFamily( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string | null { + return toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.unsupported_but_understood_family); } export function readAssistantMcpDiscoveryMetadataRouteFamily( diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts index 48ca87f..24e02d4 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts @@ -9,6 +9,7 @@ import { } from "./assistantMcpDiscoveryPilotExecutor"; import { planAssistantMcpDiscovery, + type AssistantMcpDiscoveryChainId, type AssistantMcpDiscoveryMetadataSurfaceRef, type AssistantMcpDiscoveryPlannerContract } from "./assistantMcpDiscoveryPlanner"; @@ -17,6 +18,8 @@ import type { AssistantMcpDiscoveryTurnMeaningRef } from "./assistantMcpDiscover export const ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION = "assistant_mcp_discovery_runtime_bridge_v1" as const; +export const ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION = + "assistant_mcp_discovery_loop_state_v1" as const; export type AssistantMcpDiscoveryRuntimeBridgeStatus = | "answer_draft_ready" @@ -24,6 +27,10 @@ export type AssistantMcpDiscoveryRuntimeBridgeStatus = | "needs_clarification" | "blocked" | "unsupported"; +export type AssistantMcpDiscoveryLoopStatus = + | "awaiting_clarification" + | "ready_for_next_hop" + | "blocked"; export interface AssistantMcpDiscoveryRuntimeBridgeInput { semanticDataNeed?: string | null; @@ -33,6 +40,23 @@ export interface AssistantMcpDiscoveryRuntimeBridgeInput { deps?: AssistantMcpDiscoveryPilotExecutorDeps; } +export interface AssistantMcpDiscoveryLoopStateContract { + schema_version: typeof ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION; + policy_owner: "assistantMcpDiscoveryRuntimeBridge"; + loop_status: AssistantMcpDiscoveryLoopStatus; + selected_chain_id: AssistantMcpDiscoveryChainId; + pilot_scope: AssistantMcpDiscoveryPilotExecutionContract["pilot_scope"]; + asked_domain_family: string | null; + asked_action_family: string | null; + unsupported_but_understood_family: string | null; + ranking_need: string | null; + pending_axes: string[]; + provided_axes: string[]; + explicit_entity_candidates: string[]; + explicit_organization_scope: string | null; + explicit_date_scope: string | null; +} + export interface AssistantMcpDiscoveryRuntimeBridgeContract { schema_version: typeof ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION; policy_owner: "assistantMcpDiscoveryRuntimeBridge"; @@ -41,6 +65,7 @@ export interface AssistantMcpDiscoveryRuntimeBridgeContract { planner: AssistantMcpDiscoveryPlannerContract; pilot: AssistantMcpDiscoveryPilotExecutionContract; answer_draft: AssistantMcpDiscoveryAnswerDraftContract; + loop_state: AssistantMcpDiscoveryLoopStateContract; user_facing_response_allowed: boolean; business_fact_answer_allowed: boolean; requires_user_clarification: boolean; @@ -97,6 +122,72 @@ function businessFactAnswerAllowed(draft: AssistantMcpDiscoveryAnswerDraftContra return draft.answer_mode === "confirmed_with_bounded_inference" || draft.answer_mode === "bounded_inference_only"; } +function loopStatusFor( + bridgeStatus: AssistantMcpDiscoveryRuntimeBridgeStatus +): AssistantMcpDiscoveryLoopStatus { + if (bridgeStatus === "needs_clarification") { + return "awaiting_clarification"; + } + if (bridgeStatus === "blocked" || bridgeStatus === "unsupported") { + return "blocked"; + } + return "ready_for_next_hop"; +} + +function flattenAxes( + pilot: AssistantMcpDiscoveryPilotExecutionContract, + source: "provided_axes" | "missing_axis_options" +): string[] { + const result: string[] = []; + for (const step of pilot.dry_run.execution_steps) { + if (source === "provided_axes") { + for (const axis of step.provided_axes) { + if (axis && !result.includes(axis)) { + result.push(axis); + } + } + continue; + } + for (const option of step.missing_axis_options) { + for (const axis of option) { + if (axis && !result.includes(axis)) { + result.push(axis); + } + } + } + } + return result; +} + +function entityCandidatesFromPlanner(planner: AssistantMcpDiscoveryPlannerContract): string[] { + const values = planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? []; + return uniqueStrings(values); +} + +function buildLoopState( + planner: AssistantMcpDiscoveryPlannerContract, + pilot: AssistantMcpDiscoveryPilotExecutionContract, + bridgeStatus: AssistantMcpDiscoveryRuntimeBridgeStatus +): AssistantMcpDiscoveryLoopStateContract { + return { + schema_version: ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryRuntimeBridge", + loop_status: loopStatusFor(bridgeStatus), + selected_chain_id: planner.selected_chain_id, + pilot_scope: pilot.pilot_scope, + asked_domain_family: planner.discovery_plan.turn_meaning_ref?.asked_domain_family ?? null, + asked_action_family: planner.discovery_plan.turn_meaning_ref?.asked_action_family ?? null, + unsupported_but_understood_family: + planner.discovery_plan.turn_meaning_ref?.unsupported_but_understood_family ?? null, + ranking_need: planner.data_need_graph?.ranking_need ?? planner.discovery_plan.turn_meaning_ref?.seeded_ranking_need ?? null, + pending_axes: flattenAxes(pilot, "missing_axis_options"), + provided_axes: flattenAxes(pilot, "provided_axes"), + explicit_entity_candidates: entityCandidatesFromPlanner(planner), + explicit_organization_scope: planner.discovery_plan.turn_meaning_ref?.explicit_organization_scope ?? null, + explicit_date_scope: planner.discovery_plan.turn_meaning_ref?.explicit_date_scope ?? null + }; +} + export async function runAssistantMcpDiscoveryRuntimeBridge( input: AssistantMcpDiscoveryRuntimeBridgeInput ): Promise { @@ -109,10 +200,12 @@ export async function runAssistantMcpDiscoveryRuntimeBridge( const pilot = await executeAssistantMcpDiscoveryPilot(planner, input.deps); const answerDraft = buildAssistantMcpDiscoveryAnswerDraft(pilot); const bridgeStatus = bridgeStatusFor(pilot, answerDraft); + const loopState = buildLoopState(planner, pilot, bridgeStatus); const reasonCodes = uniqueStrings([...planner.reason_codes, ...pilot.reason_codes, ...answerDraft.reason_codes]); pushReason(reasonCodes, `runtime_bridge_status_${bridgeStatus}`); pushReason(reasonCodes, "runtime_bridge_not_wired_to_hot_assistant_answer"); + pushReason(reasonCodes, `runtime_bridge_loop_state_${loopState.loop_status}`); return { schema_version: ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION, @@ -122,6 +215,7 @@ export async function runAssistantMcpDiscoveryRuntimeBridge( planner, pilot, answer_draft: answerDraft, + loop_state: loopState, user_facing_response_allowed: bridgeStatus !== "blocked", business_fact_answer_allowed: businessFactAnswerAllowed(answerDraft), requires_user_clarification: bridgeStatus === "needs_clarification", diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index b7a38fa..ccef3b6 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -339,11 +339,87 @@ function mapAddressIntentToFollowupMeaning( }; } +function mapLoopClarificationSeedToFollowupMeaning(input: { + selectedChainId: string | null; + domain: string | null; + action: string | null; + unsupported: string | null; +}): { + domain: string | null; + action: string | null; + unsupported: string | null; +} { + if (input.domain || input.action || input.unsupported) { + return { + domain: input.domain, + action: input.action, + unsupported: input.unsupported + }; + } + if (input.selectedChainId === "metadata_lane_clarification") { + return { + domain: "metadata", + action: "resolve_next_lane", + unsupported: "metadata_lane_choice_clarification" + }; + } + return { + domain: null, + action: null, + unsupported: null + }; +} + +function pilotScopeFromLoopClarificationSeed( + selectedChainId: string | null, + action: string | null +): string | null { + if (!selectedChainId) { + return null; + } + if ( + selectedChainId === "metadata_inspection" || + selectedChainId === "metadata_lane_clarification" || + selectedChainId === "catalog_drilldown" + ) { + return "metadata_inspection_v1"; + } + if (selectedChainId === "movement_evidence") { + return "counterparty_movement_evidence_query_movements_v1"; + } + if (selectedChainId === "document_evidence") { + return "counterparty_document_evidence_query_documents_v1"; + } + if (selectedChainId === "lifecycle") { + return "counterparty_lifecycle_query_documents_v1"; + } + if (selectedChainId === "entity_resolution") { + return "entity_resolution_search_v1"; + } + if (selectedChainId === "value_flow_comparison") { + return "counterparty_bidirectional_value_flow_query_movements_v1"; + } + if (selectedChainId === "value_flow_ranking" || selectedChainId === "value_flow") { + if (action === "payout") { + return "counterparty_supplier_payout_query_movements_v1"; + } + if (action === "net_value_flow") { + return "counterparty_bidirectional_value_flow_query_movements_v1"; + } + return "counterparty_value_flow_query_movements_v1"; + } + return null; +} + function collectFollowupDiscoverySeed(followupContext: Record | null): { pilotScope: string | null; domain: string | null; action: string | null; unsupported: string | null; + loopStatus: string | null; + loopSelectedChainId: string | null; + loopPendingAxes: string[]; + loopProvidedAxes: string[]; counterparty: string | null; discoveryEntity: string | null; entityResolutionStatus: string | null; @@ -362,12 +438,36 @@ function collectFollowupDiscoverySeed(followupContext: Record | const previousFilters = toRecordObject(followupContext?.previous_filters); const rootFilters = toRecordObject(followupContext?.root_filters); const pilotScope = toNonEmptyString(followupContext?.previous_discovery_pilot_scope); + const loopStatus = toNonEmptyString(followupContext?.previous_discovery_loop_status); + const loopSelectedChainId = toNonEmptyString(followupContext?.previous_discovery_loop_selected_chain_id); + const loopPendingAxes = collectEntityCandidates(followupContext?.previous_discovery_loop_pending_axes); + const loopProvidedAxes = collectEntityCandidates(followupContext?.previous_discovery_loop_provided_axes); + 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 previousIntent = toNonEmptyString(followupContext?.target_intent) ?? toNonEmptyString(followupContext?.previous_intent); + const loopMapped = + loopStatus === "awaiting_clarification" + ? mapLoopClarificationSeedToFollowupMeaning({ + selectedChainId: loopSelectedChainId, + domain: loopAskedDomainFamily, + action: loopAskedActionFamily, + unsupported: loopUnsupportedFamily + }) + : { + domain: null, + action: null, + unsupported: null + }; + const effectivePilotScope = + pilotScope ?? pilotScopeFromLoopClarificationSeed(loopSelectedChainId, loopMapped.action); const mapped = - mapPilotScopeToFollowupMeaning(pilotScope).domain !== null - ? mapPilotScopeToFollowupMeaning(pilotScope) - : mapAddressIntentToFollowupMeaning(previousIntent); + loopMapped.domain !== null || loopMapped.action !== null || loopMapped.unsupported !== null + ? loopMapped + : mapPilotScopeToFollowupMeaning(effectivePilotScope).domain !== null + ? mapPilotScopeToFollowupMeaning(effectivePilotScope) + : mapAddressIntentToFollowupMeaning(previousIntent); const discoveryEntities = collectEntityCandidates(followupContext?.previous_discovery_entity_candidates); const entityResolutionStatus = toNonEmptyString(followupContext?.previous_discovery_entity_resolution_status); const entityResolutionAmbiguityCandidates = collectEntityCandidates( @@ -392,10 +492,14 @@ function collectFollowupDiscoverySeed(followupContext: Record | collectDateScopeFromFilters(previousFilters) ?? collectDateScopeFromFilters(rootFilters); return { - pilotScope, + pilotScope: effectivePilotScope, domain: mapped.domain, action: mapped.action, unsupported: mapped.unsupported, + loopStatus, + loopSelectedChainId, + loopPendingAxes, + loopProvidedAxes, counterparty, discoveryEntity: ambiguityBlocksImplicitGrounding ? null : discoveryEntities[0] ?? null, entityResolutionStatus, @@ -1278,6 +1382,9 @@ export function buildAssistantMcpDiscoveryTurnInput( !rawDateScope && followupSeed.dateScope ); + const clarificationLoopSeedApplied = Boolean( + followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopSelectedChainId + ); const turnMeaning: AssistantMcpDiscoveryTurnMeaningRef = { asked_domain_family: @@ -1478,6 +1585,9 @@ export function buildAssistantMcpDiscoveryTurnInput( if (followupDiscoverySeedApplicable) { pushReason(reasonCodes, "mcp_discovery_seeded_from_followup_context"); } + if (clarificationLoopSeedApplied) { + pushReason(reasonCodes, "mcp_discovery_resumed_from_saved_loop_state"); + } if (effectiveMetadataFollowupSeedApplicable) { pushReason(reasonCodes, "mcp_discovery_metadata_seeded_from_followup_context"); } diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index e370198..b946fe5 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -21,6 +21,13 @@ import { readAssistantMcpDiscoveryMetadataSelectedSurfaceObjects, readAssistantMcpDiscoveryMetadataRecommendedNextPrimitive, readAssistantMcpDiscoveryRankingNeed, + readAssistantMcpDiscoveryLoopStatus, + readAssistantMcpDiscoveryLoopSelectedChainId, + readAssistantMcpDiscoveryLoopPendingAxes, + readAssistantMcpDiscoveryLoopProvidedAxes, + readAssistantMcpDiscoveryLoopAskedDomainFamily, + readAssistantMcpDiscoveryLoopAskedActionFamily, + readAssistantMcpDiscoveryLoopUnsupportedFamily, readAddressDebugTemporalScope, readAssistantMcpDiscoveryPilotScope, resolveOrganizationClarificationContinuation, @@ -695,6 +702,31 @@ export function createAssistantTransitionPolicy(deps) { carryoverSourceDebug, deps.toNonEmptyString ); + const sourceDiscoveryLoopStatus = readAssistantMcpDiscoveryLoopStatus(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryLoopSelectedChainId = readAssistantMcpDiscoveryLoopSelectedChainId( + carryoverSourceDebug, + deps.toNonEmptyString + ); + const sourceDiscoveryLoopPendingAxes = readAssistantMcpDiscoveryLoopPendingAxes( + carryoverSourceDebug, + deps.toNonEmptyString + ); + const sourceDiscoveryLoopProvidedAxes = readAssistantMcpDiscoveryLoopProvidedAxes( + carryoverSourceDebug, + deps.toNonEmptyString + ); + const sourceDiscoveryLoopAskedDomainFamily = readAssistantMcpDiscoveryLoopAskedDomainFamily( + carryoverSourceDebug, + deps.toNonEmptyString + ); + const sourceDiscoveryLoopAskedActionFamily = readAssistantMcpDiscoveryLoopAskedActionFamily( + carryoverSourceDebug, + deps.toNonEmptyString + ); + const sourceDiscoveryLoopUnsupportedFamily = readAssistantMcpDiscoveryLoopUnsupportedFamily( + carryoverSourceDebug, + deps.toNonEmptyString + ); const sourceDiscoveryRankingNeed = readAssistantMcpDiscoveryRankingNeed( carryoverSourceDebug, deps.toNonEmptyString @@ -1042,6 +1074,15 @@ export function createAssistantTransitionPolicy(deps) { previous_discovery_entity_resolution_status: sourceDiscoveryEntityResolutionStatus ?? undefined, previous_discovery_entity_candidates: sourceDiscoveryEntityCandidates.length > 0 ? sourceDiscoveryEntityCandidates : undefined, + previous_discovery_loop_status: sourceDiscoveryLoopStatus ?? undefined, + previous_discovery_loop_selected_chain_id: sourceDiscoveryLoopSelectedChainId ?? undefined, + previous_discovery_loop_pending_axes: + sourceDiscoveryLoopPendingAxes.length > 0 ? sourceDiscoveryLoopPendingAxes : undefined, + previous_discovery_loop_provided_axes: + sourceDiscoveryLoopProvidedAxes.length > 0 ? sourceDiscoveryLoopProvidedAxes : undefined, + 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_ranking_need: sourceDiscoveryRankingNeed ?? undefined, previous_discovery_entity_ambiguity_candidates: sourceDiscoveryEntityAmbiguityCandidates.length > 0 diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts index d4f1f42..a3a37d0 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts @@ -110,6 +110,46 @@ describe("assistant MCP discovery runtime bridge", () => { expect(result.answer_draft.next_step_line).not.toContain("Уточните контрагента"); }); + it("emits a resumable loop state for clarification-driven ranking chains", async () => { + const result = await runAssistantMcpDiscoveryRuntimeBridge({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "value_flow", + action_family: "turnover", + aggregation_need: null, + time_scope_need: "explicit_period", + comparison_need: null, + ranking_need: "top_desc", + proof_expectation: "coverage_checked_fact", + clarification_gaps: [], + decomposition_candidates: ["collect_scoped_movements", "aggregate_ranked_axis_values", "probe_coverage"], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"], + reason_codes: ["data_need_graph_built", "data_need_graph_ranking_top_desc"] + }, + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_date_scope: "2020" + }, + deps: buildDeps([]) + }); + + expect(result.bridge_status).toBe("needs_clarification"); + expect(result.loop_state).toMatchObject({ + loop_status: "awaiting_clarification", + selected_chain_id: "value_flow_ranking", + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + ranking_need: "top_desc", + explicit_date_scope: "2020" + }); + expect(result.loop_state.pending_axes).toContain("organization"); + expect(result.loop_state.provided_axes).toContain("aggregate_axis"); + expect(result.reason_codes).toContain("runtime_bridge_loop_state_awaiting_clarification"); + }); + it("produces a bounded ranked value-flow answer when period and organization are known", async () => { const result = await runAssistantMcpDiscoveryRuntimeBridge({ dataNeedGraph: { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index fddd698..b46ee14 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -1598,6 +1598,81 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.data_need_graph?.clarification_gaps).toEqual([]); }); + it("resumes an open-scope ranking from saved loop state even without a previous pilot scope", () => { + const orgName = "ООО Альтернатива Плюс"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "по ООО Альтернатива Плюс", + predecomposeContract: { + entities: { organization: orgName } + }, + followupContext: { + previous_discovery_loop_status: "awaiting_clarification", + previous_discovery_loop_selected_chain_id: "value_flow_ranking", + previous_discovery_loop_pending_axes: ["organization"], + previous_discovery_loop_provided_axes: ["aggregate_axis", "amount", "coverage_target"], + previous_discovery_loop_asked_domain_family: "counterparty_value", + previous_discovery_loop_asked_action_family: "turnover", + previous_discovery_loop_unsupported_family: "counterparty_value_or_turnover", + previous_discovery_ranking_need: "top_desc", + previous_filters: { + period_from: "2020-01-01", + period_to: "2020-12-31" + } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.source_signal).toBe("followup_context"); + expect(result.semantic_data_need).toBe("counterparty value-flow evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + seeded_ranking_need: "top_desc", + explicit_organization_scope: orgName, + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_value_or_turnover", + stale_replay_forbidden: true + }); + expect(result.data_need_graph?.ranking_need).toBe("top_desc"); + expect(result.reason_codes).toContain("mcp_discovery_resumed_from_saved_loop_state"); + }); + + it("resolves metadata lane choice from saved loop state even without a previous pilot scope", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "по движениям", + followupContext: { + previous_discovery_loop_status: "awaiting_clarification", + previous_discovery_loop_selected_chain_id: "metadata_lane_clarification", + previous_discovery_loop_pending_axes: ["lane_family_choice"], + previous_discovery_loop_asked_domain_family: "metadata", + previous_discovery_loop_asked_action_family: "resolve_next_lane", + previous_discovery_loop_unsupported_family: "metadata_lane_choice_clarification", + previous_filters: { + counterparty: "Группа СВК", + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + previous_discovery_metadata_ambiguity_detected: true, + previous_discovery_metadata_ambiguity_entity_sets: ["Документ", "РегистрНакопления"] + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.source_signal).toBe("followup_context"); + expect(result.semantic_data_need).toBe("movement evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "movements", + asked_action_family: "list_movements", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "movement_evidence", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_resumed_from_saved_loop_state"); + }); + it("keeps seeded ranking through a year-switch follow-up after organization clarification", () => { const orgName = "РћРћРћ Альтернатива Плюс"; const result = buildAssistantMcpDiscoveryTurnInput({ diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index 90b4c59..1f83fb3 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -1332,6 +1332,88 @@ describe("assistantTransitionPolicy", () => { expect(carryover?.followupContext?.target_intent).toBe("customer_revenue_and_payments"); }); + it("carries resumable discovery loop state into followup context", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => null, + hasAddressFollowupContextSignal: () => true + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "ООО Альтернатива Плюс", + [ + { + role: "assistant", + text: "Нужно уточнить организацию, чтобы продолжить рейтинг.", + debug: { + execution_lane: "living_chat", + mcp_discovery_response_applied: true, + assistant_mcp_discovery_entry_point_v1: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + entry_status: "bridge_executed", + turn_input: { + data_need_graph: { + business_fact_family: "value_flow", + ranking_need: "top_desc", + subject_candidates: [], + clarification_gaps: ["organization"] + }, + turn_meaning_ref: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_date_scope: "2020", + seeded_ranking_need: "top_desc" + } + }, + bridge: { + bridge_status: "needs_clarification", + business_fact_answer_allowed: false, + pilot: { + pilot_scope: "counterparty_value_flow_query_movements_v1" + }, + loop_state: { + schema_version: "assistant_mcp_discovery_loop_state_v1", + policy_owner: "assistantMcpDiscoveryRuntimeBridge", + loop_status: "awaiting_clarification", + selected_chain_id: "value_flow_ranking", + pilot_scope: "counterparty_value_flow_query_movements_v1", + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + unsupported_but_understood_family: "counterparty_value_or_turnover", + ranking_need: "top_desc", + pending_axes: ["organization"], + provided_axes: ["aggregate_axis", "amount", "coverage_target"], + explicit_entity_candidates: [], + explicit_organization_scope: null, + explicit_date_scope: "2020" + }, + answer_draft: { + answer_mode: "needs_clarification" + } + } + } + } + } + ], + null, + null, + null + ); + + 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_provided_axes).toEqual([ + "aggregate_axis", + "amount", + "coverage_target" + ]); + expect(carryover?.followupContext?.previous_discovery_loop_asked_domain_family).toBe("counterparty_value"); + expect(carryover?.followupContext?.previous_discovery_loop_asked_action_family).toBe("turnover"); + expect(carryover?.followupContext?.previous_discovery_loop_unsupported_family).toBe( + "counterparty_value_or_turnover" + ); + }); + it("carries grounded metadata downstream route hints into followup context", () => { const policy = buildPolicy({ findLastAddressAssistantItem: () => null,