From cddd6667fb358796ecc3aa38d7c8465b152e32f6 Mon Sep 17 00:00:00 2001 From: dctouch Date: Thu, 23 Apr 2026 13:42:14 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D0=B7=D0=B0=D0=BC=D0=BA=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D1=8C=20metadata-scoped=20clarification=20recovery=20loo?= =?UTF-8?q?ps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ovement_org_period_clarification_loop.json | 75 +++++++++ ..._metadata_lane_choice_with_org_inline.json | 63 ++++++++ .../services/assistantContinuityPolicy.js | 18 +++ .../assistantMcpDiscoveryDataNeedGraph.js | 37 ++++- .../services/assistantMcpDiscoveryPlanner.js | 52 +++++- .../assistantMcpDiscoveryRuntimeBridge.js | 5 + .../assistantMcpDiscoveryTurnInputAdapter.js | 81 ++++++++-- .../services/assistantTransitionPolicy.js | 4 + .../src/services/assistantContinuityPolicy.ts | 28 ++++ .../assistantMcpDiscoveryDataNeedGraph.ts | 60 +++++-- .../services/assistantMcpDiscoveryPlanner.ts | 61 ++++++- .../services/assistantMcpDiscoveryPolicy.ts | 2 + .../assistantMcpDiscoveryRuntimeBridge.ts | 9 ++ .../assistantMcpDiscoveryTurnInputAdapter.ts | 94 +++++++++-- .../src/services/assistantTransitionPolicy.ts | 11 ++ ...assistantMcpDiscoveryDataNeedGraph.test.ts | 22 +++ .../assistantMcpDiscoveryPlanner.test.ts | 82 ++++++++++ ...assistantMcpDiscoveryRuntimeBridge.test.ts | 40 +++++ ...istantMcpDiscoveryTurnInputAdapter.test.ts | 140 ++++++++++++++++ .../tests/assistantTransitionPolicy.test.ts | 152 ++++++++++++++++++ 20 files changed, 993 insertions(+), 43 deletions(-) create mode 100644 docs/orchestration/address_truth_harness_phase50_metadata_movement_org_period_clarification_loop.json create mode 100644 docs/orchestration/address_truth_harness_phase51_metadata_lane_choice_with_org_inline.json diff --git a/docs/orchestration/address_truth_harness_phase50_metadata_movement_org_period_clarification_loop.json b/docs/orchestration/address_truth_harness_phase50_metadata_movement_org_period_clarification_loop.json new file mode 100644 index 0000000..1619a7a --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase50_metadata_movement_org_period_clarification_loop.json @@ -0,0 +1,75 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase50_metadata_movement_org_period_clarification_loop", + "domain": "address_phase50_metadata_movement_org_period_clarification_loop", + "title": "Phase 50 metadata movement organization and period clarification loop", + "description": "Targeted AGENT replay for Big Block F where a metadata-born movement lane must keep both remaining gaps visible, then preserve the same loop after an organization-only clarification and ask only for the period.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_metadata_ambiguity_surface", + "title": "Metadata ambiguity is surfaced honestly for VAT", + "question": "какие объекты 1С есть по НДС?", + "allowed_reply_types": ["partial_coverage", "factual_with_explanation"], + "required_answer_patterns_all": [ + "(?i)metadata|метадан", + "(?i)ндс", + "(?i)документ|регистр" + ], + "forbidden_answer_patterns": [ + "(?i)получили", + "(?i)заплатили", + "(?i)нетто" + ], + "criticality": "critical", + "semantic_tags": ["metadata_surface", "mixed_ambiguity"] + }, + { + "step_id": "step_02_neutral_followup_requires_lane_choice", + "title": "Neutral follow-up still requires lane choice", + "question": "давай дальше", + "allowed_reply_types": ["clarification_required", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)документ", + "(?i)движени|регистр", + "(?i)уточн|выб(ери|рать)|какой контур" + ], + "criticality": "critical", + "semantic_tags": ["metadata_lane_choice_clarification", "neutral_followup"] + }, + { + "step_id": "step_03_movement_lane_requires_org_and_period", + "title": "Movement lane keeps both remaining gaps visible", + "question": "по движениям", + "allowed_reply_types": ["clarification_required", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)движени|регистр", + "(?i)организац", + "(?i)период" + ], + "forbidden_answer_patterns": [ + "(?i)уточните контрагента", + "(?i)не найден контрагент" + ], + "criticality": "critical", + "semantic_tags": ["movement_lane_after_clarification", "remaining_gaps", "organization_scope", "period_scope"] + }, + { + "step_id": "step_04_org_only_clarification_keeps_same_movement_loop", + "title": "Organization-only clarification preserves the same movement loop and asks only for the period", + "question": "по ООО Альтернатива Плюс", + "allowed_reply_types": ["clarification_required", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)движени|регистр", + "(?i)период" + ], + "forbidden_answer_patterns": [ + "(?i)организац", + "(?i)уточните контрагента", + "(?i)не найден контрагент" + ], + "criticality": "critical", + "semantic_tags": ["movement_lane_after_clarification", "organization_followup_reuse", "period_scope"] + } + ] +} diff --git a/docs/orchestration/address_truth_harness_phase51_metadata_lane_choice_with_org_inline.json b/docs/orchestration/address_truth_harness_phase51_metadata_lane_choice_with_org_inline.json new file mode 100644 index 0000000..6bb44af --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase51_metadata_lane_choice_with_org_inline.json @@ -0,0 +1,63 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase51_metadata_lane_choice_with_org_inline", + "domain": "address_phase51_metadata_lane_choice_with_org_inline", + "title": "Phase 51 metadata lane choice with inline organization clarification", + "description": "Targeted AGENT replay for Big Block F where the user resolves metadata ambiguity and supplies the organization in the same follow-up, so the movement loop should keep only the period gap alive.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_metadata_ambiguity_surface", + "title": "Metadata ambiguity is surfaced honestly for VAT", + "question": "какие объекты 1С есть по НДС?", + "allowed_reply_types": ["partial_coverage", "factual_with_explanation"], + "required_answer_patterns_all": [ + "(?i)metadata|метадан", + "(?i)ндс", + "(?i)документ|регистр" + ], + "forbidden_answer_patterns": [ + "(?i)получили", + "(?i)заплатили", + "(?i)нетто" + ], + "criticality": "critical", + "semantic_tags": ["metadata_surface", "mixed_ambiguity"] + }, + { + "step_id": "step_02_neutral_followup_requires_lane_choice", + "title": "Neutral follow-up still requires lane choice", + "question": "давай дальше", + "allowed_reply_types": ["clarification_required", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)документ", + "(?i)движени|регистр", + "(?i)уточн|выб(ери|рать)|какой контур" + ], + "criticality": "critical", + "semantic_tags": ["metadata_lane_choice_clarification", "neutral_followup"] + }, + { + "step_id": "step_03_inline_lane_choice_with_org_keeps_only_period_gap", + "title": "Movement lane plus organization in one follow-up leaves only the period gap", + "question": "по движениям по ООО Альтернатива Плюс", + "allowed_reply_types": ["clarification_required", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)движени|регистр", + "(?i)период" + ], + "forbidden_answer_patterns": [ + "(?i)уточните .*организац", + "(?i)нужн[ао].*организац", + "(?i)уточните .*контрагента", + "(?i)не найден контрагент" + ], + "criticality": "critical", + "semantic_tags": [ + "movement_lane_after_clarification", + "inline_organization_clarification", + "remaining_period_gap_only" + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js index 8123487..da1da90 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -12,6 +12,8 @@ exports.readAssistantMcpDiscoveryLoopProvidedAxes = readAssistantMcpDiscoveryLoo exports.readAssistantMcpDiscoveryLoopAskedDomainFamily = readAssistantMcpDiscoveryLoopAskedDomainFamily; exports.readAssistantMcpDiscoveryLoopAskedActionFamily = readAssistantMcpDiscoveryLoopAskedActionFamily; exports.readAssistantMcpDiscoveryLoopUnsupportedFamily = readAssistantMcpDiscoveryLoopUnsupportedFamily; +exports.readAssistantMcpDiscoveryLoopMetadataScopeHint = readAssistantMcpDiscoveryLoopMetadataScopeHint; +exports.readAssistantMcpDiscoveryLoopSubjectResolutionOptional = readAssistantMcpDiscoveryLoopSubjectResolutionOptional; exports.readAssistantMcpDiscoveryMetadataRouteFamily = readAssistantMcpDiscoveryMetadataRouteFamily; exports.readAssistantMcpDiscoveryMetadataRouteFamilySelectionBasis = readAssistantMcpDiscoveryMetadataRouteFamilySelectionBasis; exports.readAssistantMcpDiscoveryMetadataSelectedEntitySet = readAssistantMcpDiscoveryMetadataSelectedEntitySet; @@ -194,6 +196,16 @@ function readAssistantMcpDiscoveryLoopAskedActionFamily(debug, toNonEmptyString function readAssistantMcpDiscoveryLoopUnsupportedFamily(debug, toNonEmptyString = fallbackToNonEmptyString) { return toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.unsupported_but_understood_family); } +function readAssistantMcpDiscoveryLoopMetadataScopeHint(debug, toNonEmptyString = fallbackToNonEmptyString) { + return (toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.metadata_scope_hint) ?? + toNonEmptyString(readAssistantMcpDiscoveryTurnMeaning(debug)?.metadata_scope_hint) ?? + toNonEmptyString(readAssistantMcpDiscoveryDataNeedGraph(debug)?.metadata_scope_hint)); +} +function readAssistantMcpDiscoveryLoopSubjectResolutionOptional(debug) { + return (readAssistantMcpDiscoveryLoopState(debug)?.subject_resolution_optional === true || + readAssistantMcpDiscoveryTurnMeaning(debug)?.subject_resolution_optional === true || + readAssistantMcpDiscoveryDataNeedGraph(debug)?.subject_resolution_optional === true); +} function readAssistantMcpDiscoveryMetadataRouteFamily(debug, toNonEmptyString = fallbackToNonEmptyString) { return toNonEmptyString(readAssistantMcpDiscoveryDerivedMetadataSurface(debug)?.downstream_route_family); } @@ -361,6 +373,12 @@ function readAddressDebugCounterparty(debug, toNonEmptyString = fallbackToNonEmp if (String(debug?.anchor_type ?? "") === "counterparty") { return toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw); } + const discoveryPilotScope = readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString); + const suppressDiscoveryEntityCarryover = discoveryPilotScope === "metadata_inspection_v1" || + readAssistantMcpDiscoveryLoopSubjectResolutionOptional(debug); + if (suppressDiscoveryEntityCarryover) { + return null; + } const discoveryEntities = collectAssistantMcpDiscoveryEntityCandidates(debug, toNonEmptyString); for (const entity of discoveryEntities) { const text = toNonEmptyString(entity); diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js index 24fc662..a93fbfb 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js @@ -131,6 +131,10 @@ function allowsOpenScopeWithoutSubject(input) { } return Boolean(supportsOrganizationScopedOpenTotal(input.action) && (input.organizationScope || input.oneSidedOpenScopeTotalHint)); } +function allowsMetadataScopedOpenLaneWithoutSubject(input) { + return Boolean(input.subjectResolutionOptional && + (input.family === "movement_evidence" || input.family === "document_evidence")); +} function rankingNeedFromRawUtterance(value) { const text = lower(value); if (!text) { @@ -206,13 +210,17 @@ function decompositionCandidatesFor(input) { return result; } if (input.family === "movement_evidence") { - pushUnique(result, "resolve_entity_reference"); + if (!input.metadataScopedOpenLaneWithoutSubject) { + pushUnique(result, "resolve_entity_reference"); + } pushUnique(result, "fetch_scoped_movements"); pushUnique(result, "probe_coverage"); return result; } if (input.family === "document_evidence") { - pushUnique(result, "resolve_entity_reference"); + if (!input.metadataScopedOpenLaneWithoutSubject) { + pushUnique(result, "resolve_entity_reference"); + } pushUnique(result, "fetch_scoped_documents"); pushUnique(result, "probe_coverage"); return result; @@ -252,6 +260,8 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { const seededRankingNeed = toNonEmptyString(turnMeaning?.seeded_ranking_need); const explicitDateScope = toNonEmptyString(turnMeaning?.explicit_date_scope); const explicitOrganizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope); + const metadataScopeHint = toNonEmptyString(turnMeaning?.metadata_scope_hint); + const subjectResolutionOptional = turnMeaning?.subject_resolution_optional === true; const subjectCandidates = (turnMeaning?.explicit_entity_candidates ?? []) .map((item) => toNonEmptyString(item)) .filter((item) => Boolean(item)); @@ -275,6 +285,11 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { rankingNeed, oneSidedOpenScopeTotalHint }); + const metadataScopedOpenLaneWithoutSubject = subjectCandidates.length === 0 && + allowsMetadataScopedOpenLaneWithoutSubject({ + family: businessFactFamily, + subjectResolutionOptional + }); const clarificationGaps = []; if (unsupported === "metadata_lane_choice_clarification" || action === "resolve_next_lane") { pushUnique(clarificationGaps, "lane_family_choice"); @@ -285,7 +300,15 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { !explicitOrganizationScope) { pushUnique(clarificationGaps, "organization"); } - else if (subjectCandidates.length === 0 && businessFactFamily !== "schema_surface" && !openScopeWithoutSubject) { + else if (subjectCandidates.length === 0 && + metadataScopedOpenLaneWithoutSubject && + !explicitOrganizationScope) { + pushUnique(clarificationGaps, "organization"); + } + else if (subjectCandidates.length === 0 && + businessFactFamily !== "schema_surface" && + !openScopeWithoutSubject && + !metadataScopedOpenLaneWithoutSubject) { pushUnique(clarificationGaps, "subject"); } const timeScopeNeed = timeScopeNeedFor({ @@ -302,7 +325,8 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { aggregationNeed, comparisonNeed, rankingNeed, - openScopeWithoutSubject + openScopeWithoutSubject, + metadataScopedOpenLaneWithoutSubject }); const reasonCodes = []; pushReason(reasonCodes, "data_need_graph_built"); @@ -324,6 +348,9 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { if (openScopeWithoutSubject && !rankingNeed && !comparisonNeed) { pushReason(reasonCodes, "data_need_graph_open_scope_total_without_subject"); } + if (metadataScopedOpenLaneWithoutSubject) { + pushReason(reasonCodes, "data_need_graph_metadata_scoped_open_lane_without_subject"); + } if (allTimeScopeHint) { pushReason(reasonCodes, "data_need_graph_all_time_scope_hint"); } @@ -337,6 +364,8 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { schema_version: exports.ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION, policy_owner: "assistantMcpDiscoveryDataNeedGraph", subject_candidates: subjectCandidates, + metadata_scope_hint: metadataScopeHint, + subject_resolution_optional: subjectResolutionOptional || undefined, business_fact_family: businessFactFamily, action_family: toNonEmptyString(turnMeaning?.asked_action_family), aggregation_need: aggregationNeed, diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js index d08c271..894270e 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js @@ -41,6 +41,10 @@ function hasEntity(meaning) { function hasSubjectCandidates(graph) { return (graph?.subject_candidates.length ?? 0) > 0; } +function hasMetadataScopedOpenLane(graph, meaning) { + return Boolean(graph?.subject_resolution_optional === true || + meaning?.subject_resolution_optional === true); +} function hasReasonCode(graph, reasonCode) { return (graph?.reason_codes ?? []).includes(reasonCode); } @@ -58,6 +62,11 @@ function addScopeAxes(axes, meaning) { pushUnique(axes, "period"); } } +function addMetadataScopeAxis(axes, meaning) { + if (toNonEmptyString(meaning?.metadata_scope_hint)) { + pushUnique(axes, "metadata_scope"); + } +} function addTimeScopeAxes(axes, dataNeedGraph) { if (dataNeedGraph?.time_scope_need === "all_time_scope") { pushUnique(axes, "all_time_scope"); @@ -273,7 +282,7 @@ function recipeFor(input) { const graphAction = lower(dataNeedGraph?.action_family); const graphAggregation = lower(dataNeedGraph?.aggregation_need); const graphClarificationGaps = (dataNeedGraph?.clarification_gaps ?? []).map((item) => lower(item)); - const organizationScope = toNonEmptyString(meaning?.explicit_organization_scope); + const metadataScopedOpenLane = hasMetadataScopedOpenLane(dataNeedGraph, meaning); const openScopeTotalWithoutSubject = graphFactFamily === "value_flow" && !hasSubjectCandidates(dataNeedGraph) && hasReasonCode(dataNeedGraph, "data_need_graph_open_scope_total_without_subject"); @@ -281,6 +290,7 @@ function recipeFor(input) { const axes = []; const requestedAggregationAxis = aggregationAxis(meaning); addScopeAxes(axes, meaning); + addMetadataScopeAxis(axes, meaning); addTimeScopeAxes(axes, dataNeedGraph); if (graphClarificationGaps.includes("lane_family_choice")) { pushUnique(axes, "lane_family_choice"); @@ -494,6 +504,26 @@ function recipeFor(input) { }; } if (graphFactFamily === "movement_evidence") { + if (metadataScopedOpenLane) { + pushUnique(axes, "organization"); + pushUnique(axes, "coverage_target"); + const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ + dataNeedGraph, + fallbackPrimitives: ["query_movements", "probe_coverage"], + requiredAxes: axes, + metadataSurface: input.metadataSurface, + actionFamily: action + }); + return { + semanticDataNeed: "movement evidence", + chainId: "movement_evidence", + chainSummary: "Keep the metadata-scoped movement lane, ask only for the remaining business scope, then fetch scoped movement rows and probe coverage without pretending there is a grounded counterparty.", + primitives: primitiveSelection.primitives, + axes, + reason: "planner_selected_metadata_scoped_movement_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes + }; + } pushUnique(axes, "coverage_target"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, @@ -513,6 +543,26 @@ function recipeFor(input) { }; } if (graphFactFamily === "document_evidence") { + if (metadataScopedOpenLane) { + pushUnique(axes, "organization"); + pushUnique(axes, "coverage_target"); + const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ + dataNeedGraph, + fallbackPrimitives: ["query_documents", "probe_coverage"], + requiredAxes: axes, + metadataSurface: input.metadataSurface, + actionFamily: action + }); + return { + semanticDataNeed: "document evidence", + chainId: "document_evidence", + chainSummary: "Keep the metadata-scoped document lane, ask only for the remaining business scope, then fetch scoped document rows and probe coverage without pretending there is a grounded counterparty.", + primitives: primitiveSelection.primitives, + axes, + reason: "planner_selected_metadata_scoped_document_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes + }; + } pushUnique(axes, "coverage_target"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js index 27be645..c82c9e4 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js @@ -98,6 +98,11 @@ function buildLoopState(planner, pilot, bridgeStatus) { pending_axes: plannerClarificationGaps.length > 0 ? plannerClarificationGaps : flattenAxes(pilot, "missing_axis_options"), provided_axes: flattenAxes(pilot, "provided_axes"), explicit_entity_candidates: entityCandidatesFromPlanner(planner), + metadata_scope_hint: planner.discovery_plan.turn_meaning_ref?.metadata_scope_hint ?? + planner.data_need_graph?.metadata_scope_hint ?? + null, + subject_resolution_optional: planner.discovery_plan.turn_meaning_ref?.subject_resolution_optional === true || + planner.data_need_graph?.subject_resolution_optional === true, 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 }; diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 60fad67..3d64b86 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -321,6 +321,8 @@ 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 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); const loopMapped = loopStatus === "awaiting_clarification" ? mapLoopClarificationSeedToFollowupMeaning({ @@ -343,13 +345,16 @@ function collectFollowupDiscoverySeed(followupContext) { 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); - const ambiguityBlocksImplicitGrounding = pilotScope === "entity_resolution_search_v1" && entityResolutionStatus === "ambiguous"; + const ambiguityBlocksImplicitGrounding = effectivePilotScope === "entity_resolution_search_v1" && entityResolutionStatus === "ambiguous"; + const metadataPilotCarriesScopeOnly = effectivePilotScope === "metadata_inspection_v1" || loopSubjectResolutionOptional; + const metadataScopeHint = loopMetadataScopeHint ?? + (loopSubjectResolutionOptional ? discoveryEntities[0] ?? null : null); const counterparty = toNonEmptyString(previousFilters?.counterparty) ?? toNonEmptyString(rootFilters?.counterparty) ?? (toNonEmptyString(followupContext?.previous_anchor_type) === "counterparty" ? toNonEmptyString(followupContext?.previous_anchor_value) : null) ?? - (ambiguityBlocksImplicitGrounding ? null : discoveryEntities[0] ?? null); + (ambiguityBlocksImplicitGrounding || metadataPilotCarriesScopeOnly ? null : discoveryEntities[0] ?? null); const organization = toNonEmptyString(previousFilters?.organization) ?? toNonEmptyString(rootFilters?.organization) ?? (toNonEmptyString(followupContext?.previous_anchor_type) === "organization" @@ -367,12 +372,14 @@ function collectFollowupDiscoverySeed(followupContext) { loopPendingAxes, loopProvidedAxes, counterparty, - discoveryEntity: ambiguityBlocksImplicitGrounding ? null : discoveryEntities[0] ?? null, + discoveryEntity: ambiguityBlocksImplicitGrounding || loopSubjectResolutionOptional ? null : discoveryEntities[0] ?? null, entityResolutionStatus, entityResolutionAmbiguityCandidates, rankingNeed: toNonEmptyString(followupContext?.previous_discovery_ranking_need), organization, dateScope, + metadataScopeHint, + subjectResolutionOptional: loopSubjectResolutionOptional, metadataRouteFamily: normalizeMetadataRouteFamily(followupContext?.previous_discovery_metadata_route_family), metadataRouteFamilySelectionBasis: normalizeMetadataRouteFamilySelectionBasis(followupContext?.previous_discovery_metadata_route_family_selection_basis), metadataSelectedEntitySet: toNonEmptyString(followupContext?.previous_discovery_metadata_selected_entity_set), @@ -793,10 +800,15 @@ function buildAssistantMcpDiscoveryTurnInput(input) { !rawLifecycleSignal && !rawValueFlowSignal && hasMetadataObjectHint(rawText)); + const metadataLaneCarryoverAvailable = Boolean(followupSeed.counterparty || + followupSeed.discoveryEntity || + followupSeed.metadataScopeHint || + followupSeed.metadataSelectedEntitySet || + followupSeed.metadataSelectedSurfaceObjects.length > 0); const metadataGroundedDocumentFollowupApplicable = Boolean(followupSeed.pilotScope === "metadata_inspection_v1" && followupSeed.metadataRouteFamily === "document_evidence" && !followupSeed.metadataAmbiguityDetected && - followupSeed.counterparty && + metadataLaneCarryoverAvailable && !rawLifecycleSignal && !rawValueFlowSignal && metadataDocumentHintSignal); @@ -804,21 +816,21 @@ function buildAssistantMcpDiscoveryTurnInput(input) { followupSeed.metadataAmbiguityDetected && (followupSeed.metadataAmbiguityEntitySets.length === 0 || metadataEntitySetsSuggestDocumentLane(followupSeed.metadataAmbiguityEntitySets)) && - followupSeed.counterparty && + metadataLaneCarryoverAvailable && !rawLifecycleSignal && !rawValueFlowSignal && metadataDocumentHintSignal); const metadataGroundedMovementFollowupApplicable = Boolean(followupSeed.pilotScope === "metadata_inspection_v1" && followupSeed.metadataRouteFamily === "movement_evidence" && !followupSeed.metadataAmbiguityDetected && - followupSeed.counterparty && + metadataLaneCarryoverAvailable && !rawLifecycleSignal && metadataMovementHintSignal); const metadataAmbiguityResolvedMovementFollowupApplicable = Boolean(followupSeed.pilotScope === "metadata_inspection_v1" && followupSeed.metadataAmbiguityDetected && (followupSeed.metadataAmbiguityEntitySets.length === 0 || metadataEntitySetsSuggestMovementLane(followupSeed.metadataAmbiguityEntitySets)) && - followupSeed.counterparty && + metadataLaneCarryoverAvailable && !rawLifecycleSignal && metadataMovementHintSignal); const entityResolutionGroundedDocumentFollowupApplicable = Boolean(followupSeed.pilotScope === "entity_resolution_search_v1" && @@ -886,7 +898,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { (followupSeed.metadataRouteFamily === "document_evidence" || followupSeed.metadataRouteFamily === "movement_evidence") && !followupSeed.metadataAmbiguityDetected && - followupSeed.counterparty && + metadataLaneCarryoverAvailable && !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && @@ -905,7 +917,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const metadataAmbiguityCollapsedDocumentLaneContinuationApplicable = Boolean(followupSeed.pilotScope === "metadata_inspection_v1" && followupSeed.metadataAmbiguityDetected && metadataAmbiguityCollapsesToDocumentLane(followupSeed.metadataAmbiguityEntitySets) && - followupSeed.counterparty && + metadataLaneCarryoverAvailable && !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && @@ -915,7 +927,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const metadataAmbiguityCollapsedMovementLaneContinuationApplicable = Boolean(followupSeed.pilotScope === "metadata_inspection_v1" && followupSeed.metadataAmbiguityDetected && metadataAmbiguityCollapsesToMovementLane(followupSeed.metadataAmbiguityEntitySets) && - followupSeed.counterparty && + metadataLaneCarryoverAvailable && !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && @@ -926,7 +938,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { followupSeed.metadataAmbiguityDetected && !metadataAmbiguityCollapsesToDocumentLane(followupSeed.metadataAmbiguityEntitySets) && !metadataAmbiguityCollapsesToMovementLane(followupSeed.metadataAmbiguityEntitySets) && - followupSeed.counterparty && + metadataLaneCarryoverAvailable && !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && @@ -935,6 +947,14 @@ function buildAssistantMcpDiscoveryTurnInput(input) { hasMetadataDownstreamContinuationSignal(rawText)); const metadataGroundedDocumentLaneApplicable = metadataGroundedDocumentFollowupApplicable || metadataAmbiguityResolvedDocumentFollowupApplicable || + (followupSeed.subjectResolutionOptional && + !followupSeed.counterparty && + metadataLaneCarryoverAvailable && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + followupSeed.domain === "documents" && + followupSeed.action === "list_documents") || entityResolutionGroundedDocumentFollowupApplicable || entityResolutionClarifiedDocumentFollowupApplicable || valueFlowGroundedDocumentFollowupApplicable || @@ -943,6 +963,14 @@ function buildAssistantMcpDiscoveryTurnInput(input) { metadataAmbiguityCollapsedDocumentLaneContinuationApplicable; const metadataGroundedMovementLaneApplicable = metadataGroundedMovementFollowupApplicable || metadataAmbiguityResolvedMovementFollowupApplicable || + (followupSeed.subjectResolutionOptional && + !followupSeed.counterparty && + metadataLaneCarryoverAvailable && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + followupSeed.domain === "movements" && + followupSeed.action === "list_movements") || entityResolutionGroundedMovementFollowupApplicable || entityResolutionClarifiedMovementFollowupApplicable || valueFlowGroundedMovementFollowupApplicable || @@ -1009,7 +1037,17 @@ function buildAssistantMcpDiscoveryTurnInput(input) { !metadataGroundedDocumentLaneApplicable && !metadataGroundedMovementLaneApplicable }); - const groundedFollowupEntity = followupSeed.counterparty ?? followupSeed.discoveryEntity; + const metadataLaneScopeHint = rawMetadataScopeHint ?? + followupSeed.metadataScopeHint ?? + followupSeed.discoveryEntity ?? + followupSeed.metadataSelectedEntitySet ?? + null; + const metadataScopedLaneWithoutSubject = Boolean((metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable) && + !followupSeed.counterparty && + metadataLaneCarryoverAvailable); + const groundedFollowupEntity = metadataScopedLaneWithoutSubject + ? null + : followupSeed.counterparty ?? followupSeed.discoveryEntity; const entityCandidates = entityResolutionSignal ? [] : []; if (entityResolutionSignal) { pushNormalizedEntityResolutionCandidate(entityCandidates, entityResolutionClarificationCandidate); @@ -1030,11 +1068,15 @@ function buildAssistantMcpDiscoveryTurnInput(input) { pushScopedEntityCandidate(entityCandidates, normalizedPredecomposeCounterparty, groundedFollowupEntity); if (!groundedFollowupEntity) { pushScopedEntityCandidate(entityCandidates, followupSeed.counterparty, null); - pushScopedEntityCandidate(entityCandidates, followupSeed.discoveryEntity, null); + if (!metadataScopedLaneWithoutSubject) { + pushScopedEntityCandidate(entityCandidates, followupSeed.discoveryEntity, null); + } } pushScopedEntityCandidate(entityCandidates, rawEntityCandidate, groundedFollowupEntity); } - if ((rawMetadataSignal || metadataFollowupSeedApplicable) && !groundedFollowupEntity) { + if ((rawMetadataSignal || metadataFollowupSeedApplicable) && + !groundedFollowupEntity && + !metadataScopedLaneWithoutSubject) { pushUnique(entityCandidates, followupSeed.discoveryEntity); pushUnique(entityCandidates, rawMetadataScopeHint); } @@ -1126,8 +1168,10 @@ function buildAssistantMcpDiscoveryTurnInput(input) { metadata_ambiguity_entity_sets: metadataAmbiguityLaneClarificationApplicable && followupSeed.metadataAmbiguityEntitySets.length > 0 ? followupSeed.metadataAmbiguityEntitySets : undefined, + metadata_scope_hint: metadataLaneScopeHint, explicit_organization_scope: explicitOrganizationScope, explicit_date_scope: explicitDateScope, + subject_resolution_optional: metadataScopedLaneWithoutSubject || undefined, unsupported_but_understood_family: unsupported ?? (lifecycleSignal ? "counterparty_lifecycle" @@ -1181,12 +1225,18 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if ((turnMeaning.metadata_ambiguity_entity_sets?.length ?? 0) > 0) { cleanTurnMeaning.metadata_ambiguity_entity_sets = turnMeaning.metadata_ambiguity_entity_sets; } + if (toNonEmptyString(turnMeaning.metadata_scope_hint)) { + cleanTurnMeaning.metadata_scope_hint = turnMeaning.metadata_scope_hint; + } if (toNonEmptyString(turnMeaning.explicit_organization_scope)) { cleanTurnMeaning.explicit_organization_scope = turnMeaning.explicit_organization_scope; } if (toNonEmptyString(turnMeaning.explicit_date_scope)) { cleanTurnMeaning.explicit_date_scope = turnMeaning.explicit_date_scope; } + if (turnMeaning.subject_resolution_optional) { + cleanTurnMeaning.subject_resolution_optional = true; + } if (toNonEmptyString(turnMeaning.unsupported_but_understood_family)) { cleanTurnMeaning.unsupported_but_understood_family = turnMeaning.unsupported_but_understood_family; } @@ -1299,6 +1349,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (metadataAmbiguityResolvedMovementFollowupApplicable) { pushReason(reasonCodes, "mcp_discovery_metadata_ambiguity_resolved_to_movement_lane"); } + if (metadataScopedLaneWithoutSubject) { + pushReason(reasonCodes, "mcp_discovery_metadata_scoped_lane_without_subject"); + } if (entityResolutionGroundedDocumentFollowupApplicable) { pushReason(reasonCodes, "mcp_discovery_entity_resolution_grounded_document_followup"); } diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index 1b766d3..6e18e39 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -515,6 +515,8 @@ 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 sourceDiscoveryLoopMetadataScopeHint = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopMetadataScopeHint)(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryLoopSubjectResolutionOptional = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopSubjectResolutionOptional)(carryoverSourceDebug); 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); @@ -760,6 +762,8 @@ 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_metadata_scope_hint: sourceDiscoveryLoopMetadataScopeHint ?? undefined, + previous_discovery_loop_subject_resolution_optional: sourceDiscoveryLoopSubjectResolutionOptional || 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 3ad301d..11b4736 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -329,6 +329,27 @@ export function readAssistantMcpDiscoveryLoopUnsupportedFamily( return toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.unsupported_but_understood_family); } +export function readAssistantMcpDiscoveryLoopMetadataScopeHint( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string | null { + return ( + toNonEmptyString(readAssistantMcpDiscoveryLoopState(debug)?.metadata_scope_hint) ?? + toNonEmptyString(readAssistantMcpDiscoveryTurnMeaning(debug)?.metadata_scope_hint) ?? + toNonEmptyString(readAssistantMcpDiscoveryDataNeedGraph(debug)?.metadata_scope_hint) + ); +} + +export function readAssistantMcpDiscoveryLoopSubjectResolutionOptional( + debug: Record | null +): boolean { + return ( + readAssistantMcpDiscoveryLoopState(debug)?.subject_resolution_optional === true || + readAssistantMcpDiscoveryTurnMeaning(debug)?.subject_resolution_optional === true || + readAssistantMcpDiscoveryDataNeedGraph(debug)?.subject_resolution_optional === true + ); +} + export function readAssistantMcpDiscoveryMetadataRouteFamily( debug: Record | null, toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString @@ -557,6 +578,13 @@ export function readAddressDebugCounterparty( if (String(debug?.anchor_type ?? "") === "counterparty") { return toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw); } + const discoveryPilotScope = readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString); + const suppressDiscoveryEntityCarryover = + discoveryPilotScope === "metadata_inspection_v1" || + readAssistantMcpDiscoveryLoopSubjectResolutionOptional(debug); + if (suppressDiscoveryEntityCarryover) { + return null; + } const discoveryEntities = collectAssistantMcpDiscoveryEntityCandidates(debug, toNonEmptyString); for (const entity of discoveryEntities) { const text = toNonEmptyString(entity); diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts index 1c9d629..92f30e2 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts @@ -14,6 +14,8 @@ export interface AssistantMcpDiscoveryDataNeedGraphContract { schema_version: typeof ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION; policy_owner: "assistantMcpDiscoveryDataNeedGraph"; subject_candidates: string[]; + metadata_scope_hint?: string | null; + subject_resolution_optional?: boolean; business_fact_family: string | null; action_family: string | null; aggregation_need: string | null; @@ -205,6 +207,16 @@ function allowsOpenScopeWithoutSubject(input: { ); } +function allowsMetadataScopedOpenLaneWithoutSubject(input: { + family: string | null; + subjectResolutionOptional: boolean; +}): boolean { + return Boolean( + input.subjectResolutionOptional && + (input.family === "movement_evidence" || input.family === "document_evidence") + ); +} + function rankingNeedFromRawUtterance(value: string): string | null { const text = lower(value); if (!text) { @@ -249,6 +261,7 @@ function decompositionCandidatesFor(input: { comparisonNeed: string | null; rankingNeed: string | null; openScopeWithoutSubject: boolean; + metadataScopedOpenLaneWithoutSubject: boolean; }): string[] { const result: string[] = []; if (input.family === "schema_surface") { @@ -295,13 +308,17 @@ function decompositionCandidatesFor(input: { return result; } if (input.family === "movement_evidence") { - pushUnique(result, "resolve_entity_reference"); + if (!input.metadataScopedOpenLaneWithoutSubject) { + pushUnique(result, "resolve_entity_reference"); + } pushUnique(result, "fetch_scoped_movements"); pushUnique(result, "probe_coverage"); return result; } if (input.family === "document_evidence") { - pushUnique(result, "resolve_entity_reference"); + if (!input.metadataScopedOpenLaneWithoutSubject) { + pushUnique(result, "resolve_entity_reference"); + } pushUnique(result, "fetch_scoped_documents"); pushUnique(result, "probe_coverage"); return result; @@ -345,6 +362,8 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( const seededRankingNeed = toNonEmptyString(turnMeaning?.seeded_ranking_need); const explicitDateScope = toNonEmptyString(turnMeaning?.explicit_date_scope); const explicitOrganizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope); + const metadataScopeHint = toNonEmptyString(turnMeaning?.metadata_scope_hint); + const subjectResolutionOptional = turnMeaning?.subject_resolution_optional === true; const subjectCandidates = (turnMeaning?.explicit_entity_candidates ?? []) .map((item) => toNonEmptyString(item)) .filter((item): item is string => Boolean(item)); @@ -369,6 +388,12 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( rankingNeed, oneSidedOpenScopeTotalHint }); + const metadataScopedOpenLaneWithoutSubject = + subjectCandidates.length === 0 && + allowsMetadataScopedOpenLaneWithoutSubject({ + family: businessFactFamily, + subjectResolutionOptional + }); const clarificationGaps: string[] = []; if (unsupported === "metadata_lane_choice_clarification" || action === "resolve_next_lane") { pushUnique(clarificationGaps, "lane_family_choice"); @@ -380,7 +405,18 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( !explicitOrganizationScope ) { pushUnique(clarificationGaps, "organization"); - } else if (subjectCandidates.length === 0 && businessFactFamily !== "schema_surface" && !openScopeWithoutSubject) { + } else if ( + subjectCandidates.length === 0 && + metadataScopedOpenLaneWithoutSubject && + !explicitOrganizationScope + ) { + pushUnique(clarificationGaps, "organization"); + } else if ( + subjectCandidates.length === 0 && + businessFactFamily !== "schema_surface" && + !openScopeWithoutSubject && + !metadataScopedOpenLaneWithoutSubject + ) { pushUnique(clarificationGaps, "subject"); } const timeScopeNeed = timeScopeNeedFor({ @@ -393,12 +429,13 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( } const decompositionCandidates = decompositionCandidatesFor({ family: businessFactFamily, - action, - aggregationNeed, - comparisonNeed, - rankingNeed, - openScopeWithoutSubject - }); + action, + aggregationNeed, + comparisonNeed, + rankingNeed, + openScopeWithoutSubject, + metadataScopedOpenLaneWithoutSubject + }); const reasonCodes: string[] = []; pushReason(reasonCodes, "data_need_graph_built"); if (businessFactFamily) { @@ -418,6 +455,9 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( if (openScopeWithoutSubject && !rankingNeed && !comparisonNeed) { pushReason(reasonCodes, "data_need_graph_open_scope_total_without_subject"); } + if (metadataScopedOpenLaneWithoutSubject) { + pushReason(reasonCodes, "data_need_graph_metadata_scoped_open_lane_without_subject"); + } if (allTimeScopeHint) { pushReason(reasonCodes, "data_need_graph_all_time_scope_hint"); } @@ -432,6 +472,8 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( schema_version: ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION, policy_owner: "assistantMcpDiscoveryDataNeedGraph", subject_candidates: subjectCandidates, + metadata_scope_hint: metadataScopeHint, + subject_resolution_optional: subjectResolutionOptional || undefined, business_fact_family: businessFactFamily, action_family: toNonEmptyString(turnMeaning?.asked_action_family), aggregation_need: aggregationNeed, diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts index 2e9f185..c7e0fac 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts @@ -127,6 +127,16 @@ function hasSubjectCandidates(graph: AssistantMcpDiscoveryDataNeedGraphContract return (graph?.subject_candidates.length ?? 0) > 0; } +function hasMetadataScopedOpenLane( + graph: AssistantMcpDiscoveryDataNeedGraphContract | null | undefined, + meaning: AssistantMcpDiscoveryTurnMeaningRef | null | undefined +): boolean { + return Boolean( + graph?.subject_resolution_optional === true || + meaning?.subject_resolution_optional === true + ); +} + function hasReasonCode( graph: AssistantMcpDiscoveryDataNeedGraphContract | null | undefined, reasonCode: string @@ -150,6 +160,12 @@ function addScopeAxes(axes: string[], meaning: AssistantMcpDiscoveryTurnMeaningR } } +function addMetadataScopeAxis(axes: string[], meaning: AssistantMcpDiscoveryTurnMeaningRef | null | undefined): void { + if (toNonEmptyString(meaning?.metadata_scope_hint)) { + pushUnique(axes, "metadata_scope"); + } +} + function addTimeScopeAxes( axes: string[], dataNeedGraph: AssistantMcpDiscoveryDataNeedGraphContract | null | undefined @@ -417,7 +433,7 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { const graphAction = lower(dataNeedGraph?.action_family); const graphAggregation = lower(dataNeedGraph?.aggregation_need); const graphClarificationGaps = (dataNeedGraph?.clarification_gaps ?? []).map((item) => lower(item)); - const organizationScope = toNonEmptyString(meaning?.explicit_organization_scope); + const metadataScopedOpenLane = hasMetadataScopedOpenLane(dataNeedGraph, meaning); const openScopeTotalWithoutSubject = graphFactFamily === "value_flow" && !hasSubjectCandidates(dataNeedGraph) && @@ -426,6 +442,7 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { const axes: string[] = []; const requestedAggregationAxis = aggregationAxis(meaning); addScopeAxes(axes, meaning); + addMetadataScopeAxis(axes, meaning); addTimeScopeAxes(axes, dataNeedGraph); if (graphClarificationGaps.includes("lane_family_choice")) { @@ -654,6 +671,27 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { } if (graphFactFamily === "movement_evidence") { + if (metadataScopedOpenLane) { + pushUnique(axes, "organization"); + pushUnique(axes, "coverage_target"); + const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ + dataNeedGraph, + fallbackPrimitives: ["query_movements", "probe_coverage"], + requiredAxes: axes, + metadataSurface: input.metadataSurface, + actionFamily: action + }); + return { + semanticDataNeed: "movement evidence", + chainId: "movement_evidence", + chainSummary: + "Keep the metadata-scoped movement lane, ask only for the remaining business scope, then fetch scoped movement rows and probe coverage without pretending there is a grounded counterparty.", + primitives: primitiveSelection.primitives, + axes, + reason: "planner_selected_metadata_scoped_movement_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes + }; + } pushUnique(axes, "coverage_target"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, @@ -674,6 +712,27 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { } if (graphFactFamily === "document_evidence") { + if (metadataScopedOpenLane) { + pushUnique(axes, "organization"); + pushUnique(axes, "coverage_target"); + const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ + dataNeedGraph, + fallbackPrimitives: ["query_documents", "probe_coverage"], + requiredAxes: axes, + metadataSurface: input.metadataSurface, + actionFamily: action + }); + return { + semanticDataNeed: "document evidence", + chainId: "document_evidence", + chainSummary: + "Keep the metadata-scoped document lane, ask only for the remaining business scope, then fetch scoped document rows and probe coverage without pretending there is a grounded counterparty.", + primitives: primitiveSelection.primitives, + axes, + reason: "planner_selected_metadata_scoped_document_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes + }; + } pushUnique(axes, "coverage_target"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts index 9e2d7a1..c102a20 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts @@ -27,8 +27,10 @@ export interface AssistantMcpDiscoveryTurnMeaningRef { seeded_ranking_need?: string | null; explicit_entity_candidates?: string[]; metadata_ambiguity_entity_sets?: string[]; + metadata_scope_hint?: string | null; explicit_organization_scope?: string | null; explicit_date_scope?: string | null; + subject_resolution_optional?: boolean | null; meaning_confidence?: number | null; unsupported_but_understood_family?: string | null; stale_replay_forbidden?: boolean | null; diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts index 23feea5..218c711 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts @@ -53,6 +53,8 @@ export interface AssistantMcpDiscoveryLoopStateContract { pending_axes: string[]; provided_axes: string[]; explicit_entity_candidates: string[]; + metadata_scope_hint: string | null; + subject_resolution_optional: boolean; explicit_organization_scope: string | null; explicit_date_scope: string | null; } @@ -184,6 +186,13 @@ function buildLoopState( pending_axes: plannerClarificationGaps.length > 0 ? plannerClarificationGaps : flattenAxes(pilot, "missing_axis_options"), provided_axes: flattenAxes(pilot, "provided_axes"), explicit_entity_candidates: entityCandidatesFromPlanner(planner), + metadata_scope_hint: + planner.discovery_plan.turn_meaning_ref?.metadata_scope_hint ?? + planner.data_need_graph?.metadata_scope_hint ?? + null, + subject_resolution_optional: + planner.discovery_plan.turn_meaning_ref?.subject_resolution_optional === true || + planner.data_need_graph?.subject_resolution_optional === true, 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 }; diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index 2ffb3f8..bb8da2e 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -427,6 +427,8 @@ function collectFollowupDiscoverySeed(followupContext: Record | rankingNeed: string | null; organization: string | null; dateScope: string | null; + metadataScopeHint: string | null; + subjectResolutionOptional: boolean; metadataRouteFamily: AssistantMcpDiscoveryMetadataRouteFamily | null; metadataRouteFamilySelectionBasis: AssistantMcpDiscoveryMetadataSurfaceRef["route_family_selection_basis"]; metadataSelectedEntitySet: string | null; @@ -445,6 +447,9 @@ 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 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); const loopMapped = @@ -474,14 +479,19 @@ function collectFollowupDiscoverySeed(followupContext: Record | followupContext?.previous_discovery_entity_ambiguity_candidates ); const ambiguityBlocksImplicitGrounding = - pilotScope === "entity_resolution_search_v1" && entityResolutionStatus === "ambiguous"; + effectivePilotScope === "entity_resolution_search_v1" && entityResolutionStatus === "ambiguous"; + const metadataPilotCarriesScopeOnly = + effectivePilotScope === "metadata_inspection_v1" || loopSubjectResolutionOptional; + const metadataScopeHint = + loopMetadataScopeHint ?? + (loopSubjectResolutionOptional ? discoveryEntities[0] ?? null : null); const counterparty = toNonEmptyString(previousFilters?.counterparty) ?? toNonEmptyString(rootFilters?.counterparty) ?? (toNonEmptyString(followupContext?.previous_anchor_type) === "counterparty" ? toNonEmptyString(followupContext?.previous_anchor_value) : null) ?? - (ambiguityBlocksImplicitGrounding ? null : discoveryEntities[0] ?? null); + (ambiguityBlocksImplicitGrounding || metadataPilotCarriesScopeOnly ? null : discoveryEntities[0] ?? null); const organization = toNonEmptyString(previousFilters?.organization) ?? toNonEmptyString(rootFilters?.organization) ?? @@ -501,12 +511,15 @@ function collectFollowupDiscoverySeed(followupContext: Record | loopPendingAxes, loopProvidedAxes, counterparty, - discoveryEntity: ambiguityBlocksImplicitGrounding ? null : discoveryEntities[0] ?? null, + discoveryEntity: + ambiguityBlocksImplicitGrounding || loopSubjectResolutionOptional ? null : discoveryEntities[0] ?? null, entityResolutionStatus, entityResolutionAmbiguityCandidates, rankingNeed: toNonEmptyString(followupContext?.previous_discovery_ranking_need), organization, dateScope, + metadataScopeHint, + subjectResolutionOptional: loopSubjectResolutionOptional, metadataRouteFamily: normalizeMetadataRouteFamily(followupContext?.previous_discovery_metadata_route_family), metadataRouteFamilySelectionBasis: normalizeMetadataRouteFamilySelectionBasis( followupContext?.previous_discovery_metadata_route_family_selection_basis @@ -1084,11 +1097,18 @@ export function buildAssistantMcpDiscoveryTurnInput( !rawValueFlowSignal && hasMetadataObjectHint(rawText) ); + const metadataLaneCarryoverAvailable = Boolean( + followupSeed.counterparty || + followupSeed.discoveryEntity || + followupSeed.metadataScopeHint || + followupSeed.metadataSelectedEntitySet || + followupSeed.metadataSelectedSurfaceObjects.length > 0 + ); const metadataGroundedDocumentFollowupApplicable = Boolean( followupSeed.pilotScope === "metadata_inspection_v1" && followupSeed.metadataRouteFamily === "document_evidence" && !followupSeed.metadataAmbiguityDetected && - followupSeed.counterparty && + metadataLaneCarryoverAvailable && !rawLifecycleSignal && !rawValueFlowSignal && metadataDocumentHintSignal @@ -1098,7 +1118,7 @@ export function buildAssistantMcpDiscoveryTurnInput( followupSeed.metadataAmbiguityDetected && (followupSeed.metadataAmbiguityEntitySets.length === 0 || metadataEntitySetsSuggestDocumentLane(followupSeed.metadataAmbiguityEntitySets)) && - followupSeed.counterparty && + metadataLaneCarryoverAvailable && !rawLifecycleSignal && !rawValueFlowSignal && metadataDocumentHintSignal @@ -1107,7 +1127,7 @@ export function buildAssistantMcpDiscoveryTurnInput( followupSeed.pilotScope === "metadata_inspection_v1" && followupSeed.metadataRouteFamily === "movement_evidence" && !followupSeed.metadataAmbiguityDetected && - followupSeed.counterparty && + metadataLaneCarryoverAvailable && !rawLifecycleSignal && metadataMovementHintSignal ); @@ -1116,7 +1136,7 @@ export function buildAssistantMcpDiscoveryTurnInput( followupSeed.metadataAmbiguityDetected && (followupSeed.metadataAmbiguityEntitySets.length === 0 || metadataEntitySetsSuggestMovementLane(followupSeed.metadataAmbiguityEntitySets)) && - followupSeed.counterparty && + metadataLaneCarryoverAvailable && !rawLifecycleSignal && metadataMovementHintSignal ); @@ -1206,7 +1226,7 @@ export function buildAssistantMcpDiscoveryTurnInput( (followupSeed.metadataRouteFamily === "document_evidence" || followupSeed.metadataRouteFamily === "movement_evidence") && !followupSeed.metadataAmbiguityDetected && - followupSeed.counterparty && + metadataLaneCarryoverAvailable && !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && @@ -1229,7 +1249,7 @@ export function buildAssistantMcpDiscoveryTurnInput( followupSeed.pilotScope === "metadata_inspection_v1" && followupSeed.metadataAmbiguityDetected && metadataAmbiguityCollapsesToDocumentLane(followupSeed.metadataAmbiguityEntitySets) && - followupSeed.counterparty && + metadataLaneCarryoverAvailable && !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && @@ -1241,7 +1261,7 @@ export function buildAssistantMcpDiscoveryTurnInput( followupSeed.pilotScope === "metadata_inspection_v1" && followupSeed.metadataAmbiguityDetected && metadataAmbiguityCollapsesToMovementLane(followupSeed.metadataAmbiguityEntitySets) && - followupSeed.counterparty && + metadataLaneCarryoverAvailable && !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && @@ -1254,7 +1274,7 @@ export function buildAssistantMcpDiscoveryTurnInput( followupSeed.metadataAmbiguityDetected && !metadataAmbiguityCollapsesToDocumentLane(followupSeed.metadataAmbiguityEntitySets) && !metadataAmbiguityCollapsesToMovementLane(followupSeed.metadataAmbiguityEntitySets) && - followupSeed.counterparty && + metadataLaneCarryoverAvailable && !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && @@ -1265,6 +1285,14 @@ export function buildAssistantMcpDiscoveryTurnInput( const metadataGroundedDocumentLaneApplicable = metadataGroundedDocumentFollowupApplicable || metadataAmbiguityResolvedDocumentFollowupApplicable || + (followupSeed.subjectResolutionOptional && + !followupSeed.counterparty && + metadataLaneCarryoverAvailable && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + followupSeed.domain === "documents" && + followupSeed.action === "list_documents") || entityResolutionGroundedDocumentFollowupApplicable || entityResolutionClarifiedDocumentFollowupApplicable || valueFlowGroundedDocumentFollowupApplicable || @@ -1274,6 +1302,14 @@ export function buildAssistantMcpDiscoveryTurnInput( const metadataGroundedMovementLaneApplicable = metadataGroundedMovementFollowupApplicable || metadataAmbiguityResolvedMovementFollowupApplicable || + (followupSeed.subjectResolutionOptional && + !followupSeed.counterparty && + metadataLaneCarryoverAvailable && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + followupSeed.domain === "movements" && + followupSeed.action === "list_movements") || entityResolutionGroundedMovementFollowupApplicable || entityResolutionClarifiedMovementFollowupApplicable || valueFlowGroundedMovementFollowupApplicable || @@ -1346,7 +1382,20 @@ export function buildAssistantMcpDiscoveryTurnInput( !metadataGroundedDocumentLaneApplicable && !metadataGroundedMovementLaneApplicable }); - const groundedFollowupEntity = followupSeed.counterparty ?? followupSeed.discoveryEntity; + const metadataLaneScopeHint = + rawMetadataScopeHint ?? + followupSeed.metadataScopeHint ?? + followupSeed.discoveryEntity ?? + followupSeed.metadataSelectedEntitySet ?? + null; + const metadataScopedLaneWithoutSubject = Boolean( + (metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable) && + !followupSeed.counterparty && + metadataLaneCarryoverAvailable + ); + const groundedFollowupEntity = metadataScopedLaneWithoutSubject + ? null + : followupSeed.counterparty ?? followupSeed.discoveryEntity; const entityCandidates = entityResolutionSignal ? [] : []; if (entityResolutionSignal) { pushNormalizedEntityResolutionCandidate(entityCandidates, entityResolutionClarificationCandidate); @@ -1366,11 +1415,17 @@ export function buildAssistantMcpDiscoveryTurnInput( pushScopedEntityCandidate(entityCandidates, normalizedPredecomposeCounterparty, groundedFollowupEntity); if (!groundedFollowupEntity) { pushScopedEntityCandidate(entityCandidates, followupSeed.counterparty, null); - pushScopedEntityCandidate(entityCandidates, followupSeed.discoveryEntity, null); + if (!metadataScopedLaneWithoutSubject) { + pushScopedEntityCandidate(entityCandidates, followupSeed.discoveryEntity, null); + } } pushScopedEntityCandidate(entityCandidates, rawEntityCandidate, groundedFollowupEntity); } - if ((rawMetadataSignal || metadataFollowupSeedApplicable) && !groundedFollowupEntity) { + if ( + (rawMetadataSignal || metadataFollowupSeedApplicable) && + !groundedFollowupEntity && + !metadataScopedLaneWithoutSubject + ) { pushUnique(entityCandidates, followupSeed.discoveryEntity); pushUnique(entityCandidates, rawMetadataScopeHint); } @@ -1487,8 +1542,10 @@ export function buildAssistantMcpDiscoveryTurnInput( metadataAmbiguityLaneClarificationApplicable && followupSeed.metadataAmbiguityEntitySets.length > 0 ? followupSeed.metadataAmbiguityEntitySets : undefined, + metadata_scope_hint: metadataLaneScopeHint, explicit_organization_scope: explicitOrganizationScope, explicit_date_scope: explicitDateScope, + subject_resolution_optional: metadataScopedLaneWithoutSubject || undefined, unsupported_but_understood_family: unsupported ?? (lifecycleSignal @@ -1546,12 +1603,18 @@ export function buildAssistantMcpDiscoveryTurnInput( if ((turnMeaning.metadata_ambiguity_entity_sets?.length ?? 0) > 0) { cleanTurnMeaning.metadata_ambiguity_entity_sets = turnMeaning.metadata_ambiguity_entity_sets; } + if (toNonEmptyString(turnMeaning.metadata_scope_hint)) { + cleanTurnMeaning.metadata_scope_hint = turnMeaning.metadata_scope_hint; + } if (toNonEmptyString(turnMeaning.explicit_organization_scope)) { cleanTurnMeaning.explicit_organization_scope = turnMeaning.explicit_organization_scope; } if (toNonEmptyString(turnMeaning.explicit_date_scope)) { cleanTurnMeaning.explicit_date_scope = turnMeaning.explicit_date_scope; } + if (turnMeaning.subject_resolution_optional) { + cleanTurnMeaning.subject_resolution_optional = true; + } if (toNonEmptyString(turnMeaning.unsupported_but_understood_family)) { cleanTurnMeaning.unsupported_but_understood_family = turnMeaning.unsupported_but_understood_family; } @@ -1668,6 +1731,9 @@ export function buildAssistantMcpDiscoveryTurnInput( if (metadataAmbiguityResolvedMovementFollowupApplicable) { pushReason(reasonCodes, "mcp_discovery_metadata_ambiguity_resolved_to_movement_lane"); } + if (metadataScopedLaneWithoutSubject) { + pushReason(reasonCodes, "mcp_discovery_metadata_scoped_lane_without_subject"); + } if (entityResolutionGroundedDocumentFollowupApplicable) { pushReason(reasonCodes, "mcp_discovery_entity_resolution_grounded_document_followup"); } diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index b946fe5..5c38a0c 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -28,6 +28,8 @@ import { readAssistantMcpDiscoveryLoopAskedDomainFamily, readAssistantMcpDiscoveryLoopAskedActionFamily, readAssistantMcpDiscoveryLoopUnsupportedFamily, + readAssistantMcpDiscoveryLoopMetadataScopeHint, + readAssistantMcpDiscoveryLoopSubjectResolutionOptional, readAddressDebugTemporalScope, readAssistantMcpDiscoveryPilotScope, resolveOrganizationClarificationContinuation, @@ -727,6 +729,12 @@ export function createAssistantTransitionPolicy(deps) { carryoverSourceDebug, deps.toNonEmptyString ); + const sourceDiscoveryLoopMetadataScopeHint = readAssistantMcpDiscoveryLoopMetadataScopeHint( + carryoverSourceDebug, + deps.toNonEmptyString + ); + const sourceDiscoveryLoopSubjectResolutionOptional = + readAssistantMcpDiscoveryLoopSubjectResolutionOptional(carryoverSourceDebug); const sourceDiscoveryRankingNeed = readAssistantMcpDiscoveryRankingNeed( carryoverSourceDebug, deps.toNonEmptyString @@ -1083,6 +1091,9 @@ 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_metadata_scope_hint: sourceDiscoveryLoopMetadataScopeHint ?? undefined, + previous_discovery_loop_subject_resolution_optional: + sourceDiscoveryLoopSubjectResolutionOptional || undefined, previous_discovery_ranking_need: sourceDiscoveryRankingNeed ?? undefined, previous_discovery_entity_ambiguity_candidates: sourceDiscoveryEntityAmbiguityCandidates.length > 0 diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts index 2ab8951..1f575b7 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts @@ -229,4 +229,26 @@ describe("assistant MCP discovery data need graph", () => { expect(result.reason_codes).toContain("data_need_graph_open_scope_total_without_subject"); expect(result.reason_codes).toContain("data_need_graph_all_time_scope_hint"); }); + + it("treats metadata-scoped movement evidence as subjectless and asks only for organization plus period", () => { + const result = buildAssistantMcpDiscoveryDataNeedGraph({ + semanticDataNeed: "movement evidence", + rawUtterance: "РїРѕ движениям", + turnMeaning: { + asked_domain_family: "movements", + asked_action_family: "list_movements", + metadata_scope_hint: "НДС", + subject_resolution_optional: true, + unsupported_but_understood_family: "movement_evidence" + } + }); + + expect(result.business_fact_family).toBe("movement_evidence"); + expect(result.subject_candidates).toEqual([]); + expect(result.metadata_scope_hint).toBe("НДС"); + expect(result.subject_resolution_optional).toBe(true); + expect(result.clarification_gaps).toEqual(["organization", "period"]); + expect(result.decomposition_candidates).toEqual(["fetch_scoped_movements", "probe_coverage"]); + expect(result.reason_codes).toContain("data_need_graph_metadata_scoped_open_lane_without_subject"); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts index f828ded..aef750f 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts @@ -823,4 +823,86 @@ describe("assistant MCP discovery planner", () => { expect(result.catalog_review.review_status).toBe("catalog_compatible"); expect(result.reason_codes).toContain("planner_selected_open_scope_value_flow_total_from_data_need_graph"); }); + + it("keeps metadata-scoped movement evidence in clarification instead of forcing entity resolution", () => { + const result = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + metadata_scope_hint: "НДС", + subject_resolution_optional: true, + business_fact_family: "movement_evidence", + action_family: "list_movements", + aggregation_need: null, + time_scope_need: "period_required", + comparison_need: null, + ranking_need: null, + proof_expectation: "clarification_required", + clarification_gaps: ["organization", "period"], + decomposition_candidates: ["fetch_scoped_movements", "probe_coverage"], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"], + reason_codes: [ + "data_need_graph_built", + "data_need_graph_metadata_scoped_open_lane_without_subject" + ] + }, + turnMeaning: { + asked_domain_family: "movements", + asked_action_family: "list_movements", + metadata_scope_hint: "НДС", + subject_resolution_optional: true, + unsupported_but_understood_family: "movement_evidence" + } + }); + + expect(result.planner_status).toBe("needs_clarification"); + expect(result.selected_chain_id).toBe("movement_evidence"); + expect(result.proposed_primitives).toEqual(["query_movements", "probe_coverage"]); + expect(result.required_axes).toEqual(["metadata_scope", "organization", "coverage_target"]); + expect(result.reason_codes).toContain("planner_selected_metadata_scoped_movement_from_data_need_graph"); + expect(result.selected_chain_id).not.toBe("entity_resolution"); + }); + + it("keeps metadata-scoped movement evidence execution-ready once organization and period are known", () => { + const result = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + metadata_scope_hint: "НДС", + subject_resolution_optional: true, + business_fact_family: "movement_evidence", + action_family: "list_movements", + aggregation_need: null, + time_scope_need: "explicit_period", + comparison_need: null, + ranking_need: null, + proof_expectation: "coverage_checked_fact", + clarification_gaps: [], + decomposition_candidates: ["fetch_scoped_movements", "probe_coverage"], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"], + reason_codes: [ + "data_need_graph_built", + "data_need_graph_metadata_scoped_open_lane_without_subject" + ] + }, + turnMeaning: { + asked_domain_family: "movements", + asked_action_family: "list_movements", + metadata_scope_hint: "НДС", + subject_resolution_optional: true, + explicit_organization_scope: "РћРћРћ Альтернатива Плюс", + explicit_date_scope: "2020", + unsupported_but_understood_family: "movement_evidence" + } + }); + + expect(result.planner_status).toBe("ready_for_execution"); + expect(result.selected_chain_id).toBe("movement_evidence"); + expect(result.proposed_primitives).toEqual(["query_movements", "probe_coverage"]); + expect(result.required_axes).toEqual(["organization", "period", "metadata_scope", "coverage_target"]); + expect(result.catalog_review.review_status).toBe("catalog_compatible"); + expect(result.reason_codes).toContain("planner_selected_metadata_scoped_movement_from_data_need_graph"); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts index a3a37d0..2b6ea45 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts @@ -429,4 +429,44 @@ describe("assistant MCP discovery runtime bridge", () => { "\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430" ); }); + it("persists metadata scope and subject-optional flags in the resumable loop state", async () => { + const result = await runAssistantMcpDiscoveryRuntimeBridge({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + metadata_scope_hint: "\u041d\u0414\u0421", + subject_resolution_optional: true, + business_fact_family: "movement_evidence", + action_family: "list_movements", + aggregation_need: null, + time_scope_need: "period_required", + comparison_need: null, + ranking_need: null, + proof_expectation: "clarification_required", + clarification_gaps: ["organization", "period"], + decomposition_candidates: ["fetch_scoped_movements", "probe_coverage"], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"], + reason_codes: ["data_need_graph_built", "data_need_graph_metadata_scoped_open_lane_without_subject"] + }, + turnMeaning: { + asked_domain_family: "movements", + asked_action_family: "list_movements", + metadata_scope_hint: "\u041d\u0414\u0421", + subject_resolution_optional: true, + unsupported_but_understood_family: "movement_evidence" + }, + deps: buildDeps([]) + }); + + expect(result.bridge_status).toBe("needs_clarification"); + expect(result.loop_state).toMatchObject({ + loop_status: "awaiting_clarification", + selected_chain_id: "movement_evidence", + metadata_scope_hint: "\u041d\u0414\u0421", + subject_resolution_optional: true + }); + expect(result.loop_state.pending_axes).toEqual(["organization", "period"]); + expect(result.loop_state.explicit_entity_candidates).toEqual([]); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index 0351d93..174b8dc 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -1877,4 +1877,144 @@ describe("assistant MCP discovery turn input adapter", () => { ); expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); }); + + it.skip("keeps metadata-born movement lane subjectless and asks for organization plus period", () => { + 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_discovery_entity_candidates: ["НДС"], + 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.semantic_data_need).toBe("movement evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "movements", + asked_action_family: "list_movements", + metadata_scope_hint: "НДС", + subject_resolution_optional: true, + unsupported_but_understood_family: "movement_evidence", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.data_need_graph?.clarification_gaps).toEqual(["organization", "period"]); + expect(result.reason_codes).toContain("mcp_discovery_metadata_ambiguity_resolved_to_movement_lane"); + expect(result.reason_codes).toContain("mcp_discovery_metadata_scoped_lane_without_subject"); + }); + + it.skip("keeps metadata scope through organization-only clarification and leaves only period pending", () => { + const orgName = "РћРћРћ Альтернатива Плюс"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "РїРѕ РћРћРћ Альтернатива Плюс", + predecomposeContract: { + entities: { organization: orgName } + }, + followupContext: { + previous_discovery_loop_status: "awaiting_clarification", + previous_discovery_loop_selected_chain_id: "movement_evidence", + previous_discovery_loop_pending_axes: ["organization", "period"], + previous_discovery_loop_asked_domain_family: "movements", + previous_discovery_loop_asked_action_family: "list_movements", + previous_discovery_loop_unsupported_family: "movement_evidence", + previous_discovery_entity_candidates: ["НДС"] + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("movement evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "movements", + asked_action_family: "list_movements", + metadata_scope_hint: "НДС", + subject_resolution_optional: true, + explicit_organization_scope: orgName, + unsupported_but_understood_family: "movement_evidence", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.data_need_graph?.clarification_gaps).toEqual(["period"]); + }); + it("keeps metadata-born movement lane subjectless and asks for organization plus period (utf8-safe)", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "\u043f\u043e \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f\u043c", + 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_discovery_entity_candidates: ["\u041d\u0414\u0421"], + previous_discovery_metadata_ambiguity_detected: true, + previous_discovery_metadata_ambiguity_entity_sets: [ + "\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442", + "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u041d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f" + ] + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("movement evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "movements", + asked_action_family: "list_movements", + metadata_scope_hint: "\u041d\u0414\u0421", + subject_resolution_optional: true, + unsupported_but_understood_family: "movement_evidence", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.data_need_graph?.clarification_gaps).toEqual(["organization", "period"]); + expect(result.reason_codes).toContain("mcp_discovery_metadata_ambiguity_resolved_to_movement_lane"); + expect(result.reason_codes).toContain("mcp_discovery_metadata_scoped_lane_without_subject"); + }); + + it("keeps metadata scope through organization-only clarification and leaves only period pending (utf8-safe)", () => { + const orgName = + "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: + "\u043f\u043e \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441", + predecomposeContract: { + entities: { organization: orgName } + }, + followupContext: { + previous_discovery_loop_status: "awaiting_clarification", + previous_discovery_loop_selected_chain_id: "movement_evidence", + previous_discovery_loop_pending_axes: ["organization", "period"], + previous_discovery_loop_asked_domain_family: "movements", + previous_discovery_loop_asked_action_family: "list_movements", + previous_discovery_loop_unsupported_family: "movement_evidence", + previous_discovery_loop_metadata_scope_hint: "\u041d\u0414\u0421", + previous_discovery_loop_subject_resolution_optional: true, + previous_discovery_entity_candidates: ["\u041d\u0414\u0421"] + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("movement evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "movements", + asked_action_family: "list_movements", + metadata_scope_hint: "\u041d\u0414\u0421", + subject_resolution_optional: true, + explicit_organization_scope: orgName, + unsupported_but_understood_family: "movement_evidence", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.data_need_graph?.clarification_gaps).toEqual(["period"]); + }); }); diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index 1f83fb3..e05578b 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -1647,4 +1647,156 @@ describe("assistantTransitionPolicy", () => { period_to: "2017-05-31" }); }); + it("carries metadata-scoped subjectless loop state through follow-up context", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: "\u043d\u0443\u0436\u043d\u044b \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044f \u0438 \u043f\u0435\u0440\u0438\u043e\u0434", + 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: { + turn_meaning_ref: { + asked_domain_family: "movements", + asked_action_family: "list_movements", + metadata_scope_hint: "\u041d\u0414\u0421", + subject_resolution_optional: true, + unsupported_but_understood_family: "movement_evidence", + stale_replay_forbidden: true + } + }, + bridge: { + bridge_status: "needs_clarification", + business_fact_answer_allowed: false, + pilot: { + pilot_scope: "counterparty_movement_evidence_query_movements_v1" + }, + loop_state: { + schema_version: "assistant_mcp_discovery_loop_state_v1", + policy_owner: "assistantMcpDiscoveryRuntimeBridge", + loop_status: "awaiting_clarification", + selected_chain_id: "movement_evidence", + pilot_scope: "counterparty_movement_evidence_query_movements_v1", + asked_domain_family: "movements", + asked_action_family: "list_movements", + unsupported_but_understood_family: "movement_evidence", + ranking_need: null, + pending_axes: ["organization", "period"], + provided_axes: [], + explicit_entity_candidates: [], + metadata_scope_hint: "\u041d\u0414\u0421", + subject_resolution_optional: true, + explicit_organization_scope: null, + explicit_date_scope: null + }, + answer_draft: { + answer_mode: "needs_clarification" + } + } + } + } + }), + hasAddressFollowupContextSignal: () => true, + hasReferentialPointer: () => false, + resolveAddressIntent: () => ({ intent: "unknown" }), + resolveAddressIntentFamily: () => null, + resolveAssistantTurnMeaning: () => null + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "\u043f\u043e \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441", + [{ kind: "assistant", text: "\u043d\u0443\u0436\u043d\u044b \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044f \u0438 \u043f\u0435\u0440\u0438\u043e\u0434" }], + "\u043f\u043e \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441", + { predecomposeContract: { intent: "unknown" } }, + null + ); + + expect(carryover?.followupContext?.previous_discovery_loop_selected_chain_id).toBe("movement_evidence"); + expect(carryover?.followupContext?.previous_discovery_loop_pending_axes).toEqual([ + "organization", + "period" + ]); + expect(carryover?.followupContext?.previous_discovery_loop_metadata_scope_hint).toBe( + "\u041d\u0414\u0421" + ); + expect(carryover?.followupContext?.previous_discovery_loop_subject_resolution_optional).toBe(true); + }); + it("does not backfill metadata scope into counterparty carryover during lane choice follow-up", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: "\u0443\u0442\u043e\u0447\u043d\u0438\u0442\u0435: \u043f\u043e \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u043c \u0438\u043b\u0438 \u043f\u043e \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f\u043c?", + 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: { + turn_meaning_ref: { + asked_domain_family: "metadata", + asked_action_family: "resolve_next_lane", + explicit_entity_candidates: ["\u041d\u0414\u0421"], + metadata_scope_hint: "\u041d\u0414\u0421", + metadata_ambiguity_entity_sets: [ + "\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442", + "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u041d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f" + ], + unsupported_but_understood_family: "metadata_lane_choice_clarification", + stale_replay_forbidden: true + } + }, + bridge: { + bridge_status: "needs_clarification", + business_fact_answer_allowed: false, + pilot: { + pilot_scope: "metadata_inspection_v1" + }, + loop_state: { + schema_version: "assistant_mcp_discovery_loop_state_v1", + policy_owner: "assistantMcpDiscoveryRuntimeBridge", + loop_status: "awaiting_clarification", + selected_chain_id: "metadata_lane_clarification", + pilot_scope: "metadata_inspection_v1", + asked_domain_family: "metadata", + asked_action_family: "resolve_next_lane", + unsupported_but_understood_family: "metadata_lane_choice_clarification", + ranking_need: null, + pending_axes: ["lane_family_choice"], + provided_axes: [], + explicit_entity_candidates: ["\u041d\u0414\u0421"], + metadata_scope_hint: "\u041d\u0414\u0421", + subject_resolution_optional: false, + explicit_organization_scope: null, + explicit_date_scope: null + }, + answer_draft: { + answer_mode: "needs_clarification" + } + } + } + } + }), + hasAddressFollowupContextSignal: () => true, + hasReferentialPointer: () => false, + resolveAddressIntent: () => ({ intent: "unknown" }), + resolveAddressIntentFamily: () => null, + resolveAssistantTurnMeaning: () => null + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "\u043f\u043e \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f\u043c", + [{ kind: "assistant", text: "\u0443\u0442\u043e\u0447\u043d\u0438\u0442\u0435: \u043f\u043e \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u043c \u0438\u043b\u0438 \u043f\u043e \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f\u043c?" }], + "\u043f\u043e \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f\u043c", + { predecomposeContract: { intent: "unknown" } }, + null + ); + + expect(carryover?.followupContext?.previous_filters?.counterparty).toBeUndefined(); + expect(carryover?.followupContext?.previous_anchor_type).toBeUndefined(); + expect(carryover?.followupContext?.previous_anchor_value).toBeNull(); + expect(carryover?.followupContext?.previous_discovery_entity_candidates).toEqual(["\u041d\u0414\u0421"]); + expect(carryover?.followupContext?.previous_discovery_pilot_scope).toBe("metadata_inspection_v1"); + }); });